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