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