Skip to main content

omni_dev/atlassian/
adf_attr_schema.rs

1//! ADF per-node attribute schemas.
2//!
3//! Validates the `attrs` map on each ADF node against an upstream-derived
4//! schema describing required/optional fields and their accepted shapes
5//! (enum, integer range, URL, etc.).
6//!
7//! # Source of truth
8//!
9//! Attribute schemas are transcribed from
10//! `packages/adf-schema/src/schema/nodes/<node>.ts` and
11//! `packages/adf-schema/src/schema/marks/<mark>.ts` in the upstream
12//! `@atlaskit/adf-schema` tarball pinned by
13//! [`crate::atlassian::adf_schema::SCHEMA_VERSION`] /
14//! [`crate::atlassian::adf_schema::UPSTREAM_TARBALL_SHA256`]. Each schema
15//! entry cites the upstream file so refresh reviews are line-by-line
16//! tractable.
17//!
18//! # Forward compatibility
19//!
20//! - Unknown node types are permissive (no validation runs). A future
21//!   Atlassian schema addition does not start producing violations.
22//! - Unknown attribute names are permissive (only declared fields are
23//!   checked). This keeps round-trip safe — Atlassian sometimes adds
24//!   optional fields that omni-dev's snapshot doesn't yet describe.
25//! - `serde_json::Value::Null` for an optional field is treated as
26//!   "absent" (matches Atlassian's payload conventions).
27//!
28//! # Coverage in this slice (PR #733-attrs)
29//!
30//! Schemas are encoded for the node types whose attribute mistakes are
31//! user-visible and easy to produce by hand:
32//!
33//! - `panel.panelType`, `heading.level`, `media.type`, `mediaSingle.layout`,
34//!   `taskItem.state`, `decisionItem.state`, `taskList.localId`,
35//!   `decisionList.localId`, `status.color`, `extension.extensionType`,
36//!   `extension.extensionKey`, `mention.id`, `date.timestamp`,
37//!   `emoji.shortName`, `embedCard.url`, `expand.title`,
38//!   `nestedExpand.title`, `orderedList.order`, `layoutColumn.width`,
39//!   `codeBlock.language`, `bodiedExtension.extensionType`/`.extensionKey`.
40//!
41//! Mark-attribute schemas (`link.href`, `textColor.color`, …) live in the
42//! mark-validation slice (PR #733-marks) and reuse the same `AttrType` /
43//! `AttrProblem` machinery defined here.
44
45use std::collections::HashMap;
46use std::sync::LazyLock;
47
48use serde_json::Value;
49
50use crate::atlassian::adf_schema::AdfSchemaViolation;
51
52// -----------------------------------------------------------------------------
53// Attribute-type primitives
54// -----------------------------------------------------------------------------
55
56/// Whether an attribute must be present.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum AttrPresence {
59    /// The attribute must be present (and non-null).
60    Required,
61    /// The attribute may be present or absent. Null is treated as absent.
62    Optional,
63}
64
65/// The accepted shape of an attribute value.
66#[derive(Debug, Clone, PartialEq)]
67pub enum AttrType {
68    /// One of a finite list of string values, case-sensitive.
69    Enum(&'static [&'static str]),
70    /// An integer (no fractional part) in `[lo, hi]` inclusive.
71    IntRange(i64, i64),
72    /// A number in `[lo, hi]` inclusive (accepts integers).
73    NumRange(f64, f64),
74    /// A number whose accepted `[lo, hi]` range depends on a sibling
75    /// attribute's value.
76    ///
77    /// When the sibling attribute named `sibling` equals the string `equals`,
78    /// the value is checked against `when_true`; otherwise (sibling absent,
79    /// non-string, or a different string) against `when_false`.
80    ///
81    /// Models the upstream `mediaSingle.width` `anyOf`: pixel widths
82    /// (`widthType = "pixel"`) are unbounded, percentage widths (the default)
83    /// cap at 100. Resolution requires the full attr map, so it happens in
84    /// [`validate_attrs`] before [`check_value`] runs.
85    CondNumRange {
86        /// Name of the sibling attribute that selects the range.
87        sibling: &'static str,
88        /// Sibling string value that selects [`Self::CondNumRange::when_true`].
89        equals: &'static str,
90        /// Inclusive `(lo, hi)` used when the sibling equals `equals`.
91        when_true: (f64, f64),
92        /// Inclusive `(lo, hi)` used otherwise.
93        when_false: (f64, f64),
94    },
95    /// A boolean.
96    Bool,
97    /// Any JSON string (no further validation).
98    String,
99    /// A string that parses as an absolute URL.
100    Url,
101    /// A JSON object (any shape).
102    Object,
103    /// Any JSON value. Used for fields whose shape we have not audited.
104    Free,
105}
106
107/// What is wrong with an attribute value, surfaced inside
108/// [`AdfSchemaViolation::InvalidAttr`].
109#[derive(Debug, Clone, PartialEq)]
110pub enum AttrProblem {
111    /// The value is a string but not in the allowed enum.
112    NotInEnum {
113        /// The accepted values, in declaration order.
114        allowed: Vec<&'static str>,
115        /// The actual value supplied (rendered as a string for display).
116        actual: String,
117    },
118    /// The value is an integer outside the accepted range.
119    OutOfRange {
120        /// Inclusive lower bound.
121        lo: i64,
122        /// Inclusive upper bound.
123        hi: i64,
124        /// The actual value supplied.
125        actual: i64,
126    },
127    /// The value is a number outside the accepted range.
128    OutOfRangeF {
129        /// Inclusive lower bound.
130        lo: f64,
131        /// Inclusive upper bound.
132        hi: f64,
133        /// The actual value supplied.
134        actual: f64,
135    },
136    /// The value's JSON kind is wrong.
137    WrongType {
138        /// What was expected (e.g. `"string"`, `"integer"`, `"object"`).
139        expected: &'static str,
140    },
141    /// The value is a string but doesn't satisfy a structured constraint.
142    BadFormat {
143        /// Short reason (e.g. `"not a valid URL"`).
144        reason: &'static str,
145    },
146}
147
148impl std::fmt::Display for AttrProblem {
149    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150        match self {
151            Self::NotInEnum { allowed, actual } => {
152                let allowed_str = allowed
153                    .iter()
154                    .map(|a| format!("'{a}'"))
155                    .collect::<Vec<_>>()
156                    .join(", ");
157                write!(
158                    f,
159                    "value '{actual}' is not in the allowed set ({allowed_str})"
160                )
161            }
162            Self::OutOfRange { lo, hi, actual } => {
163                write!(
164                    f,
165                    "value {actual} is outside the allowed range [{lo}, {hi}]"
166                )
167            }
168            Self::OutOfRangeF { lo, hi, actual } => {
169                write!(
170                    f,
171                    "value {actual} is outside the allowed range [{lo}, {hi}]"
172                )
173            }
174            Self::WrongType { expected } => {
175                write!(f, "value has wrong type (expected {expected})")
176            }
177            Self::BadFormat { reason } => write!(f, "{reason}"),
178        }
179    }
180}
181
182/// Schema describing the legal `attrs` for one node (or mark) type.
183#[derive(Debug, Clone)]
184pub struct AttrSchema {
185    /// Field name → (type, presence). Order is preserved for diff stability
186    /// against the upstream source.
187    pub fields: &'static [(&'static str, AttrType, AttrPresence)],
188}
189
190// -----------------------------------------------------------------------------
191// Per-node attribute schemas
192// -----------------------------------------------------------------------------
193
194const ENUM_PANEL_TYPE: &[&str] = &["info", "note", "warning", "success", "error", "custom"];
195
196const ENUM_TASK_STATE: &[&str] = &["TODO", "DONE"];
197
198const ENUM_DECISION_STATE: &[&str] = &["DECIDED", "UNDECIDED"];
199
200const ENUM_MEDIA_TYPE: &[&str] = &["file", "link", "external"];
201
202const ENUM_MEDIA_SINGLE_LAYOUT: &[&str] = &[
203    "align-end",
204    "align-start",
205    "center",
206    "full-width",
207    "wide",
208    "wrap-left",
209    "wrap-right",
210];
211
212const ENUM_STATUS_COLOR: &[&str] = &["neutral", "purple", "blue", "red", "yellow", "green"];
213
214const ENUM_MENTION_USER_TYPE: &[&str] = &["DEFAULT", "SPECIAL", "APP", "TEAM"];
215
216const ENUM_EXTENSION_LAYOUT: &[&str] = &["default", "wide", "full-width"];
217
218// Per-node entries. Each entry cites the upstream source file. Sorted
219// alphabetically by node type for diffability.
220type AttrEntry = (&'static str, AttrSchema);
221
222const ATTR_ENTRIES: &[AttrEntry] = &[
223    // blockCard — definitions/blockCard_node
224    // upstream: { url?: string, data?: object }
225    // We accept either; both optional in our snapshot to keep round-trip
226    // tolerant of API responses that vary the shape.
227    (
228        "blockCard",
229        AttrSchema {
230            fields: &[
231                ("url", AttrType::Url, AttrPresence::Optional),
232                ("data", AttrType::Object, AttrPresence::Optional),
233            ],
234        },
235    ),
236    // bodiedExtension — definitions/bodiedExtension_node
237    (
238        "bodiedExtension",
239        AttrSchema {
240            fields: &[
241                ("extensionType", AttrType::String, AttrPresence::Required),
242                ("extensionKey", AttrType::String, AttrPresence::Required),
243                (
244                    "layout",
245                    AttrType::Enum(ENUM_EXTENSION_LAYOUT),
246                    AttrPresence::Optional,
247                ),
248                ("parameters", AttrType::Object, AttrPresence::Optional),
249                ("text", AttrType::String, AttrPresence::Optional),
250            ],
251        },
252    ),
253    // codeBlock — definitions/codeBlock_node
254    // upstream: { language?: string }
255    (
256        "codeBlock",
257        AttrSchema {
258            fields: &[("language", AttrType::String, AttrPresence::Optional)],
259        },
260    ),
261    // date — definitions/date_node
262    // upstream: { timestamp: string } (epoch-ms as a string, e.g. "1690000000000")
263    (
264        "date",
265        AttrSchema {
266            fields: &[("timestamp", AttrType::String, AttrPresence::Required)],
267        },
268    ),
269    // decisionItem — definitions/decisionItem_node
270    (
271        "decisionItem",
272        AttrSchema {
273            fields: &[
274                ("localId", AttrType::String, AttrPresence::Required),
275                (
276                    "state",
277                    AttrType::Enum(ENUM_DECISION_STATE),
278                    AttrPresence::Required,
279                ),
280            ],
281        },
282    ),
283    // decisionList — definitions/decisionList_node
284    (
285        "decisionList",
286        AttrSchema {
287            fields: &[("localId", AttrType::String, AttrPresence::Required)],
288        },
289    ),
290    // embedCard — definitions/embedCard_node
291    (
292        "embedCard",
293        AttrSchema {
294            fields: &[
295                ("url", AttrType::Url, AttrPresence::Required),
296                (
297                    "layout",
298                    AttrType::Enum(ENUM_EXTENSION_LAYOUT),
299                    AttrPresence::Optional,
300                ),
301                (
302                    "width",
303                    AttrType::NumRange(0.0, 100.0),
304                    AttrPresence::Optional,
305                ),
306                (
307                    "originalHeight",
308                    AttrType::NumRange(0.0, f64::MAX),
309                    AttrPresence::Optional,
310                ),
311                (
312                    "originalWidth",
313                    AttrType::NumRange(0.0, f64::MAX),
314                    AttrPresence::Optional,
315                ),
316            ],
317        },
318    ),
319    // emoji — definitions/emoji_node
320    (
321        "emoji",
322        AttrSchema {
323            fields: &[
324                ("shortName", AttrType::String, AttrPresence::Required),
325                ("id", AttrType::String, AttrPresence::Optional),
326                ("text", AttrType::String, AttrPresence::Optional),
327            ],
328        },
329    ),
330    // expand — definitions/expand_node
331    (
332        "expand",
333        AttrSchema {
334            fields: &[("title", AttrType::String, AttrPresence::Optional)],
335        },
336    ),
337    // extension — definitions/extension_node
338    (
339        "extension",
340        AttrSchema {
341            fields: &[
342                ("extensionType", AttrType::String, AttrPresence::Required),
343                ("extensionKey", AttrType::String, AttrPresence::Required),
344                (
345                    "layout",
346                    AttrType::Enum(ENUM_EXTENSION_LAYOUT),
347                    AttrPresence::Optional,
348                ),
349                ("parameters", AttrType::Object, AttrPresence::Optional),
350                ("text", AttrType::String, AttrPresence::Optional),
351            ],
352        },
353    ),
354    // heading — definitions/heading_node
355    // upstream: { level: 1..=6 }
356    (
357        "heading",
358        AttrSchema {
359            fields: &[("level", AttrType::IntRange(1, 6), AttrPresence::Required)],
360        },
361    ),
362    // inlineCard — definitions/inlineCard_node
363    (
364        "inlineCard",
365        AttrSchema {
366            fields: &[
367                ("url", AttrType::Url, AttrPresence::Optional),
368                ("data", AttrType::Object, AttrPresence::Optional),
369            ],
370        },
371    ),
372    // layoutColumn — definitions/layoutColumn_node
373    // upstream: { width: number 0..=100 }
374    (
375        "layoutColumn",
376        AttrSchema {
377            fields: &[(
378                "width",
379                AttrType::NumRange(0.0, 100.0),
380                AttrPresence::Required,
381            )],
382        },
383    ),
384    // media — definitions/media_node
385    (
386        "media",
387        AttrSchema {
388            fields: &[
389                (
390                    "type",
391                    AttrType::Enum(ENUM_MEDIA_TYPE),
392                    AttrPresence::Required,
393                ),
394                ("id", AttrType::String, AttrPresence::Optional),
395                ("collection", AttrType::String, AttrPresence::Optional),
396                ("url", AttrType::String, AttrPresence::Optional),
397                ("alt", AttrType::String, AttrPresence::Optional),
398                (
399                    "width",
400                    AttrType::NumRange(0.0, f64::MAX),
401                    AttrPresence::Optional,
402                ),
403                (
404                    "height",
405                    AttrType::NumRange(0.0, f64::MAX),
406                    AttrPresence::Optional,
407                ),
408                ("occurrenceKey", AttrType::String, AttrPresence::Optional),
409            ],
410        },
411    ),
412    // mediaSingle — definitions/mediaSingle_node
413    // upstream `attrs` is an `anyOf`: width ∈ [0, 100] for the percentage
414    // branch (the default when widthType is absent), width ∈ [0, ∞) for the
415    // pixel branch (widthType = "pixel"). Real Confluence emits pixel widths
416    // well above 100 for editor-sized images, so the range must branch on
417    // widthType rather than cap unconditionally (issue #1037).
418    (
419        "mediaSingle",
420        AttrSchema {
421            fields: &[
422                (
423                    "layout",
424                    AttrType::Enum(ENUM_MEDIA_SINGLE_LAYOUT),
425                    AttrPresence::Optional,
426                ),
427                (
428                    "width",
429                    AttrType::CondNumRange {
430                        sibling: "widthType",
431                        equals: "pixel",
432                        when_true: (0.0, f64::MAX),
433                        when_false: (0.0, 100.0),
434                    },
435                    AttrPresence::Optional,
436                ),
437                ("widthType", AttrType::String, AttrPresence::Optional),
438            ],
439        },
440    ),
441    // mention — definitions/mention_node
442    (
443        "mention",
444        AttrSchema {
445            fields: &[
446                ("id", AttrType::String, AttrPresence::Required),
447                ("text", AttrType::String, AttrPresence::Optional),
448                (
449                    "userType",
450                    AttrType::Enum(ENUM_MENTION_USER_TYPE),
451                    AttrPresence::Optional,
452                ),
453                ("accessLevel", AttrType::String, AttrPresence::Optional),
454            ],
455        },
456    ),
457    // nestedExpand — definitions/nestedExpand_node
458    (
459        "nestedExpand",
460        AttrSchema {
461            fields: &[("title", AttrType::String, AttrPresence::Optional)],
462        },
463    ),
464    // orderedList — definitions/orderedList_node
465    // upstream: { order?: positive integer }
466    (
467        "orderedList",
468        AttrSchema {
469            fields: &[(
470                "order",
471                AttrType::IntRange(0, i64::MAX),
472                AttrPresence::Optional,
473            )],
474        },
475    ),
476    // panel — definitions/panel_node
477    // upstream: { panelType: enum }
478    (
479        "panel",
480        AttrSchema {
481            fields: &[(
482                "panelType",
483                AttrType::Enum(ENUM_PANEL_TYPE),
484                AttrPresence::Required,
485            )],
486        },
487    ),
488    // status — definitions/status_node
489    (
490        "status",
491        AttrSchema {
492            fields: &[
493                ("text", AttrType::String, AttrPresence::Required),
494                (
495                    "color",
496                    AttrType::Enum(ENUM_STATUS_COLOR),
497                    AttrPresence::Required,
498                ),
499                ("localId", AttrType::String, AttrPresence::Optional),
500                ("style", AttrType::String, AttrPresence::Optional),
501            ],
502        },
503    ),
504    // taskItem — definitions/taskItem_node
505    (
506        "taskItem",
507        AttrSchema {
508            fields: &[
509                ("localId", AttrType::String, AttrPresence::Required),
510                (
511                    "state",
512                    AttrType::Enum(ENUM_TASK_STATE),
513                    AttrPresence::Required,
514                ),
515            ],
516        },
517    ),
518    // taskList — definitions/taskList_node
519    (
520        "taskList",
521        AttrSchema {
522            fields: &[("localId", AttrType::String, AttrPresence::Required)],
523        },
524    ),
525];
526
527static ATTR_SCHEMAS: LazyLock<HashMap<&'static str, &'static AttrSchema>> = LazyLock::new(|| {
528    ATTR_ENTRIES
529        .iter()
530        .map(|(node_type, schema)| (*node_type, schema))
531        .collect()
532});
533
534/// Returns the attribute schema for a node type, or `None` if not registered.
535#[must_use]
536pub fn attr_schema(node_type: &str) -> Option<&'static AttrSchema> {
537    ATTR_SCHEMAS.get(node_type).copied()
538}
539
540// -----------------------------------------------------------------------------
541// Attribute validation
542// -----------------------------------------------------------------------------
543
544/// Validates `attrs` against the schema for `node_type`, appending any
545/// violations to `out`.
546///
547/// `path` should be the index path from the document root to the node whose
548/// attrs are being validated. Each emitted violation will carry that path.
549///
550/// If `node_type` has no registered schema, no violations are emitted (the
551/// validator is permissive on unknown node types). If the schema declares
552/// fields but `attrs` is `None` and there are no required fields, no
553/// violations are emitted either.
554pub fn validate_attrs(
555    node_type: &str,
556    attrs: Option<&Value>,
557    path: &[usize],
558    out: &mut Vec<AdfSchemaViolation>,
559) {
560    let Some(schema) = attr_schema(node_type) else {
561        return;
562    };
563
564    // Treat `Some(Null)` and missing object both as "absent".
565    let attr_obj = match attrs {
566        Some(Value::Object(map)) => Some(map),
567        Some(Value::Null) | None => None,
568        Some(_other) => {
569            // attrs is present but not an object — every required field is
570            // effectively missing; flag the most common failure (any field of
571            // wrong shape) by reporting one MissingAttr per required field.
572            // This mirrors how Atlassian's renderer treats malformed attrs.
573            for (field, _ty, presence) in schema.fields {
574                if *presence == AttrPresence::Required {
575                    out.push(AdfSchemaViolation::MissingAttr {
576                        node_type: node_type.to_string(),
577                        attr_name: (*field).to_string(),
578                        path: path.to_vec(),
579                    });
580                }
581            }
582            return;
583        }
584    };
585
586    for (field, ty, presence) in schema.fields {
587        let value = attr_obj.and_then(|m| m.get(*field));
588
589        // Treat explicit Null as absent.
590        let value = match value {
591            Some(Value::Null) | None => None,
592            Some(v) => Some(v),
593        };
594
595        match (value, *presence) {
596            (None, AttrPresence::Required) => {
597                out.push(AdfSchemaViolation::MissingAttr {
598                    node_type: node_type.to_string(),
599                    attr_name: (*field).to_string(),
600                    path: path.to_vec(),
601                });
602            }
603            (None, AttrPresence::Optional) => {
604                // Absent and optional — fine.
605            }
606            (Some(v), _) => {
607                // Resolve sibling-conditional types (e.g. mediaSingle.width
608                // depends on widthType) against the full attr map before
609                // shape-checking; non-conditional types pass through unchanged.
610                let effective = resolve_attr_type(ty, attr_obj);
611                if let Some(problem) = check_value(&effective, v) {
612                    out.push(AdfSchemaViolation::InvalidAttr {
613                        node_type: node_type.to_string(),
614                        attr_name: (*field).to_string(),
615                        problem,
616                        path: path.to_vec(),
617                    });
618                }
619            }
620        }
621    }
622}
623
624/// Resolves a possibly sibling-conditional [`AttrType`] into a concrete one,
625/// using the node's full attribute map to pick the branch.
626///
627/// [`AttrType::CondNumRange`] collapses to a plain [`AttrType::NumRange`]
628/// chosen by the sibling attribute; every other type is returned unchanged
629/// (a cheap clone — all variants hold only `Copy` data or `&'static` slices).
630fn resolve_attr_type(ty: &AttrType, attrs: Option<&serde_json::Map<String, Value>>) -> AttrType {
631    match ty {
632        AttrType::CondNumRange {
633            sibling,
634            equals,
635            when_true,
636            when_false,
637        } => {
638            let selected =
639                attrs.and_then(|m| m.get(*sibling)).and_then(Value::as_str) == Some(*equals);
640            let (lo, hi) = if selected { *when_true } else { *when_false };
641            AttrType::NumRange(lo, hi)
642        }
643        other => other.clone(),
644    }
645}
646
647/// Validates a single value against an [`AttrType`].
648///
649/// Returns `Some(problem)` describing what's wrong, or `None` if the value
650/// is acceptable. Public so that mark-attribute validation
651/// ([`crate::atlassian::adf_mark_schema`]) can reuse the same shape rules.
652///
653/// [`AttrType::CondNumRange`] should be resolved via [`resolve_attr_type`]
654/// before reaching here (it needs sibling context this function lacks); if one
655/// arrives unresolved, the stricter `when_false` range is applied so the check
656/// is never silently permissive.
657#[must_use]
658pub fn check_value(ty: &AttrType, value: &Value) -> Option<AttrProblem> {
659    match ty {
660        AttrType::Enum(allowed) => match value.as_str() {
661            Some(s) if allowed.contains(&s) => None,
662            Some(s) => Some(AttrProblem::NotInEnum {
663                allowed: allowed.to_vec(),
664                actual: s.to_string(),
665            }),
666            None => Some(AttrProblem::WrongType { expected: "string" }),
667        },
668        AttrType::IntRange(lo, hi) => match value.as_i64() {
669            Some(n) if n >= *lo && n <= *hi => None,
670            Some(n) => Some(AttrProblem::OutOfRange {
671                lo: *lo,
672                hi: *hi,
673                actual: n,
674            }),
675            None => Some(AttrProblem::WrongType {
676                expected: "integer",
677            }),
678        },
679        AttrType::NumRange(lo, hi) => match value.as_f64() {
680            Some(n) if n >= *lo && n <= *hi => None,
681            Some(n) => Some(AttrProblem::OutOfRangeF {
682                lo: *lo,
683                hi: *hi,
684                actual: n,
685            }),
686            None => Some(AttrProblem::WrongType { expected: "number" }),
687        },
688        AttrType::CondNumRange { when_false, .. } => {
689            // Reached only if a conditional type bypassed `resolve_attr_type`
690            // (no sibling context). Apply the stricter branch defensively.
691            check_value(&AttrType::NumRange(when_false.0, when_false.1), value)
692        }
693        AttrType::Bool => match value.as_bool() {
694            Some(_) => None,
695            None => Some(AttrProblem::WrongType { expected: "bool" }),
696        },
697        AttrType::String => match value.as_str() {
698            Some(_) => None,
699            None => Some(AttrProblem::WrongType { expected: "string" }),
700        },
701        AttrType::Url => match value.as_str() {
702            Some(s) => match url::Url::parse(s) {
703                Ok(_) => None,
704                Err(_) => Some(AttrProblem::BadFormat {
705                    reason: "not a valid URL",
706                }),
707            },
708            None => Some(AttrProblem::WrongType { expected: "string" }),
709        },
710        AttrType::Object => match value {
711            Value::Object(_) => None,
712            _ => Some(AttrProblem::WrongType { expected: "object" }),
713        },
714        AttrType::Free => None,
715    }
716}
717
718#[cfg(test)]
719#[allow(clippy::unwrap_used, clippy::expect_used)]
720mod tests {
721    use super::*;
722    use serde_json::json;
723
724    fn run(node_type: &str, attrs: Value) -> Vec<AdfSchemaViolation> {
725        let mut out = Vec::new();
726        validate_attrs(node_type, Some(&attrs), &[], &mut out);
727        out
728    }
729
730    fn run_no_attrs(node_type: &str) -> Vec<AdfSchemaViolation> {
731        let mut out = Vec::new();
732        validate_attrs(node_type, None, &[], &mut out);
733        out
734    }
735
736    #[test]
737    fn panel_panel_type_known_value_validates() {
738        for value in ENUM_PANEL_TYPE {
739            assert!(
740                run("panel", json!({ "panelType": value })).is_empty(),
741                "panelType '{value}' should validate"
742            );
743        }
744    }
745
746    #[test]
747    fn panel_panel_type_unknown_value_flagged() {
748        let v = run("panel", json!({ "panelType": "purple" }));
749        assert_eq!(v.len(), 1);
750        match &v[0] {
751            AdfSchemaViolation::InvalidAttr {
752                node_type,
753                attr_name,
754                problem,
755                ..
756            } => {
757                assert_eq!(node_type, "panel");
758                assert_eq!(attr_name, "panelType");
759                assert!(matches!(problem, AttrProblem::NotInEnum { .. }));
760            }
761            other => panic!("expected InvalidAttr, got {other:?}"),
762        }
763    }
764
765    #[test]
766    fn panel_missing_panel_type_flagged() {
767        let v = run("panel", json!({}));
768        assert_eq!(v.len(), 1);
769        match &v[0] {
770            AdfSchemaViolation::MissingAttr {
771                node_type,
772                attr_name,
773                ..
774            } => {
775                assert_eq!(node_type, "panel");
776                assert_eq!(attr_name, "panelType");
777            }
778            other => panic!("expected MissingAttr, got {other:?}"),
779        }
780    }
781
782    #[test]
783    fn panel_missing_attrs_object_flagged() {
784        let v = run_no_attrs("panel");
785        assert_eq!(v.len(), 1);
786        assert!(matches!(v[0], AdfSchemaViolation::MissingAttr { .. }));
787    }
788
789    #[test]
790    fn heading_level_in_range_validates() {
791        for level in 1_i64..=6 {
792            assert!(run("heading", json!({ "level": level })).is_empty());
793        }
794    }
795
796    #[test]
797    fn heading_level_out_of_range_flagged() {
798        let v = run("heading", json!({ "level": 7 }));
799        assert_eq!(v.len(), 1);
800        match &v[0] {
801            AdfSchemaViolation::InvalidAttr {
802                attr_name, problem, ..
803            } => {
804                assert_eq!(attr_name, "level");
805                assert!(matches!(
806                    problem,
807                    AttrProblem::OutOfRange {
808                        lo: 1,
809                        hi: 6,
810                        actual: 7
811                    }
812                ));
813            }
814            other => panic!("expected InvalidAttr, got {other:?}"),
815        }
816    }
817
818    #[test]
819    fn heading_level_wrong_type_flagged() {
820        let v = run("heading", json!({ "level": "two" }));
821        assert_eq!(v.len(), 1);
822        match &v[0] {
823            AdfSchemaViolation::InvalidAttr { problem, .. } => {
824                assert!(matches!(
825                    problem,
826                    AttrProblem::WrongType {
827                        expected: "integer"
828                    }
829                ));
830            }
831            other => panic!("expected InvalidAttr, got {other:?}"),
832        }
833    }
834
835    #[test]
836    fn heading_missing_level_flagged_as_missing() {
837        let v = run("heading", json!({}));
838        assert_eq!(v.len(), 1);
839        assert!(
840            matches!(&v[0], AdfSchemaViolation::MissingAttr { attr_name, .. } if attr_name == "level")
841        );
842    }
843
844    #[test]
845    fn task_item_known_state_validates() {
846        for state in ENUM_TASK_STATE {
847            assert!(run("taskItem", json!({ "localId": "abc", "state": state })).is_empty());
848        }
849    }
850
851    #[test]
852    fn task_item_unknown_state_flagged() {
853        let v = run(
854            "taskItem",
855            json!({ "localId": "abc", "state": "INPROGRESS" }),
856        );
857        assert_eq!(v.len(), 1);
858        match &v[0] {
859            AdfSchemaViolation::InvalidAttr { attr_name, .. } => {
860                assert_eq!(attr_name, "state");
861            }
862            other => panic!("expected InvalidAttr, got {other:?}"),
863        }
864    }
865
866    #[test]
867    fn media_single_layout_known_validates() {
868        assert!(run("mediaSingle", json!({ "layout": "center" })).is_empty());
869        assert!(run("mediaSingle", json!({ "layout": "wide" })).is_empty());
870    }
871
872    #[test]
873    fn media_single_layout_misspelled_flagged() {
874        let v = run("mediaSingle", json!({ "layout": "centre" }));
875        assert_eq!(v.len(), 1);
876        assert!(matches!(
877            &v[0],
878            AdfSchemaViolation::InvalidAttr { attr_name, .. } if attr_name == "layout"
879        ));
880    }
881
882    #[test]
883    fn media_single_pixel_width_above_100_validates() {
884        // The issue #1037 reproducer: real Confluence emits pixel widths well
885        // above 100 for editor-sized images. With widthType:pixel the value is
886        // unbounded, so width=900 must validate.
887        assert!(run(
888            "mediaSingle",
889            json!({ "layout": "center", "width": 900, "widthType": "pixel" })
890        )
891        .is_empty());
892    }
893
894    #[test]
895    fn media_single_percentage_width_above_100_flagged() {
896        // The percentage branch (default when widthType is absent, or explicit)
897        // still caps at 100 — width=900 must be flagged in both forms.
898        for attrs in [
899            json!({ "layout": "center", "width": 900 }),
900            json!({ "layout": "center", "width": 900, "widthType": "percentage" }),
901        ] {
902            let v = run("mediaSingle", attrs.clone());
903            assert_eq!(v.len(), 1, "expected one violation for {attrs}");
904            assert!(matches!(
905                &v[0],
906                AdfSchemaViolation::InvalidAttr { attr_name, problem, .. }
907                    if attr_name == "width"
908                        && matches!(problem, AttrProblem::OutOfRangeF { .. })
909            ));
910        }
911    }
912
913    #[test]
914    fn check_value_cond_num_range_falls_back_to_strict_branch() {
915        // `check_value` has no sibling context, so a `CondNumRange` reaching it
916        // directly (bypassing `resolve_attr_type`) applies the stricter
917        // `when_false` branch: 900 is rejected, 50 accepted.
918        let ty = AttrType::CondNumRange {
919            sibling: "widthType",
920            equals: "pixel",
921            when_true: (0.0, f64::MAX),
922            when_false: (0.0, 100.0),
923        };
924        assert!(matches!(
925            check_value(&ty, &json!(900)),
926            Some(AttrProblem::OutOfRangeF { .. })
927        ));
928        assert!(check_value(&ty, &json!(50)).is_none());
929    }
930
931    #[test]
932    fn media_single_percentage_width_in_range_validates() {
933        assert!(run(
934            "mediaSingle",
935            json!({ "layout": "center", "width": 75, "widthType": "percentage" })
936        )
937        .is_empty());
938        // widthType absent defaults to the percentage branch.
939        assert!(run("mediaSingle", json!({ "layout": "center", "width": 75 })).is_empty());
940    }
941
942    #[test]
943    fn media_type_required() {
944        let v = run("media", json!({}));
945        assert_eq!(v.len(), 1);
946        assert!(matches!(
947            &v[0],
948            AdfSchemaViolation::MissingAttr { attr_name, .. } if attr_name == "type"
949        ));
950    }
951
952    #[test]
953    fn embed_card_url_format() {
954        assert!(run("embedCard", json!({ "url": "https://example.com" })).is_empty());
955        let v = run("embedCard", json!({ "url": "not a url" }));
956        assert_eq!(v.len(), 1);
957        match &v[0] {
958            AdfSchemaViolation::InvalidAttr { problem, .. } => {
959                assert!(matches!(problem, AttrProblem::BadFormat { .. }));
960            }
961            other => panic!("expected InvalidAttr, got {other:?}"),
962        }
963    }
964
965    #[test]
966    fn layout_column_width_in_range() {
967        assert!(run("layoutColumn", json!({ "width": 33.3 })).is_empty());
968        let v = run("layoutColumn", json!({ "width": 150 }));
969        assert_eq!(v.len(), 1);
970        assert!(matches!(
971            &v[0],
972            AdfSchemaViolation::InvalidAttr {
973                problem: AttrProblem::OutOfRangeF { .. },
974                ..
975            }
976        ));
977    }
978
979    #[test]
980    fn ordered_list_order_optional() {
981        assert!(run("orderedList", json!({})).is_empty());
982        assert!(run("orderedList", json!({ "order": 5 })).is_empty());
983        // Negative not in our IntRange(0, MAX) — flagged.
984        let v = run("orderedList", json!({ "order": -1 }));
985        assert_eq!(v.len(), 1);
986    }
987
988    #[test]
989    fn unknown_node_type_is_permissive() {
990        assert!(run("madeUpNode", json!({ "anyField": "anyValue" })).is_empty());
991    }
992
993    #[test]
994    fn unknown_field_under_known_node_is_permissive() {
995        // panel only declares panelType; an extra unknown field is ignored.
996        assert!(run("panel", json!({ "panelType": "info", "futureField": "ok" })).is_empty());
997    }
998
999    #[test]
1000    fn null_attribute_treated_as_absent() {
1001        // status.localId is optional; null is fine.
1002        assert!(run(
1003            "status",
1004            json!({ "text": "hi", "color": "blue", "localId": null })
1005        )
1006        .is_empty());
1007        // status.color is required; null is treated as absent → MissingAttr.
1008        let v = run("status", json!({ "text": "hi", "color": null }));
1009        assert!(matches!(
1010            &v[0],
1011            AdfSchemaViolation::MissingAttr { attr_name, .. } if attr_name == "color"
1012        ));
1013    }
1014
1015    #[test]
1016    fn attrs_array_treated_as_invalid_object() {
1017        // Wrong-shape attrs (not an object): every required field flagged
1018        // missing.
1019        let mut out = Vec::new();
1020        validate_attrs("panel", Some(&json!([1, 2, 3])), &[], &mut out);
1021        assert_eq!(out.len(), 1);
1022        assert!(matches!(
1023            &out[0],
1024            AdfSchemaViolation::MissingAttr { attr_name, .. } if attr_name == "panelType"
1025        ));
1026    }
1027
1028    #[test]
1029    fn attr_problem_display_messages() {
1030        let p = AttrProblem::NotInEnum {
1031            allowed: vec!["info", "note"],
1032            actual: "purple".to_string(),
1033        };
1034        let s = p.to_string();
1035        assert!(s.contains("'purple'"), "got: {s}");
1036        assert!(s.contains("'info'"), "got: {s}");
1037
1038        let p = AttrProblem::OutOfRange {
1039            lo: 1,
1040            hi: 6,
1041            actual: 7,
1042        };
1043        assert!(p.to_string().contains("[1, 6]"));
1044
1045        let p = AttrProblem::BadFormat {
1046            reason: "not a valid URL",
1047        };
1048        assert_eq!(p.to_string(), "not a valid URL");
1049
1050        let p = AttrProblem::WrongType {
1051            expected: "integer",
1052        };
1053        assert!(p.to_string().contains("integer"));
1054    }
1055
1056    #[test]
1057    fn attr_problem_out_of_range_f_display() {
1058        // Float-range Display arm — never exercised by the per-node fixture
1059        // tests above because their NumRange tests trigger the message
1060        // through the field validator, not directly. Cover it here.
1061        let p = AttrProblem::OutOfRangeF {
1062            lo: 0.0,
1063            hi: 100.0,
1064            actual: 200.0,
1065        };
1066        let s = p.to_string();
1067        assert!(s.contains("200"), "got: {s}");
1068        assert!(s.contains("[0, 100]"), "got: {s}");
1069    }
1070
1071    // ── check_value: WrongType arms for every AttrType ──────────────
1072    //
1073    // Covers the `None =>` branch of each match in `check_value` that
1074    // converts a wrongly-typed JSON value into `AttrProblem::WrongType`.
1075    // Most of these aren't reachable via the per-node fixture tests
1076    // because each declared field is exercised with the *correct* shape;
1077    // these tests drive `check_value` directly with a deliberately
1078    // wrong value.
1079
1080    #[test]
1081    fn check_value_enum_wrong_type_for_non_string() {
1082        let ty = AttrType::Enum(&["a", "b"]);
1083        let p = check_value(&ty, &json!(123)).expect("should reject");
1084        assert!(matches!(p, AttrProblem::WrongType { expected: "string" }));
1085    }
1086
1087    #[test]
1088    fn check_value_int_range_wrong_type_for_non_integer() {
1089        let ty = AttrType::IntRange(0, 10);
1090        let p = check_value(&ty, &json!("abc")).expect("should reject");
1091        assert!(matches!(
1092            p,
1093            AttrProblem::WrongType {
1094                expected: "integer"
1095            }
1096        ));
1097    }
1098
1099    #[test]
1100    fn check_value_num_range_wrong_type_for_non_number() {
1101        let ty = AttrType::NumRange(0.0, 100.0);
1102        let p = check_value(&ty, &json!("abc")).expect("should reject");
1103        assert!(matches!(p, AttrProblem::WrongType { expected: "number" }));
1104    }
1105
1106    #[test]
1107    fn check_value_bool_arms() {
1108        let ty = AttrType::Bool;
1109        assert!(check_value(&ty, &json!(true)).is_none());
1110        let p = check_value(&ty, &json!("yes")).expect("should reject");
1111        assert!(matches!(p, AttrProblem::WrongType { expected: "bool" }));
1112    }
1113
1114    #[test]
1115    fn check_value_string_arms() {
1116        let ty = AttrType::String;
1117        assert!(check_value(&ty, &json!("hi")).is_none());
1118        let p = check_value(&ty, &json!(42)).expect("should reject");
1119        assert!(matches!(p, AttrProblem::WrongType { expected: "string" }));
1120    }
1121
1122    #[test]
1123    fn check_value_url_wrong_type_for_non_string() {
1124        let ty = AttrType::Url;
1125        let p = check_value(&ty, &json!(42)).expect("should reject");
1126        assert!(matches!(p, AttrProblem::WrongType { expected: "string" }));
1127    }
1128
1129    #[test]
1130    fn check_value_object_arms() {
1131        let ty = AttrType::Object;
1132        assert!(check_value(&ty, &json!({"k": "v"})).is_none());
1133        let p = check_value(&ty, &json!([1, 2])).expect("should reject");
1134        assert!(matches!(p, AttrProblem::WrongType { expected: "object" }));
1135    }
1136
1137    #[test]
1138    fn check_value_free_accepts_anything() {
1139        let ty = AttrType::Free;
1140        assert!(check_value(&ty, &json!(null)).is_none());
1141        assert!(check_value(&ty, &json!(42)).is_none());
1142        assert!(check_value(&ty, &json!("x")).is_none());
1143        assert!(check_value(&ty, &json!({"k": "v"})).is_none());
1144        assert!(check_value(&ty, &json!([1, 2, 3])).is_none());
1145    }
1146}