Skip to main content

omni_dev/atlassian/
adf_mark_schema.rs

1//! ADF per-context mark allow-lists and per-mark attribute schemas.
2//!
3//! Validates the `marks` array on each ADF node against:
4//!
5//! 1. **Allow-list by context.** Marks on an *inline* node (text, hardBreak,
6//!    mention, …) are checked against the *parent* container's inline-mark
7//!    allow-list — e.g. `code` on text inside `paragraph` is fine, on text
8//!    inside `heading` it is not. Marks on a *block* node (paragraph,
9//!    heading, tableCell, …) are checked against that node type's
10//!    block-mark allow-list.
11//!
12//! 2. **Per-mark attribute schema.** Re-uses the
13//!    [`crate::atlassian::adf_attr_schema::AttrSchema`] /
14//!    [`crate::atlassian::adf_attr_schema::AttrType`] machinery from the
15//!    second sub-PR. `link.href` must parse as a URL, `subsup.type` must
16//!    be `sub` or `sup`, etc.
17//!
18//! # Source of truth
19//!
20//! Lists are transcribed from
21//! `packages/adf-schema/src/schema/marks/<mark>.ts` and the per-node
22//! `inlineContent` / `marks` declarations in the upstream tarball pinned by
23//! [`crate::atlassian::adf_schema::SCHEMA_VERSION`]. Mark groups (e.g.
24//! `formatting`) are flattened into per-context allow-lists for direct
25//! lookup; the trade-off (a slightly larger table vs. a runtime group
26//! resolver) is the same one ADR-0023 made for content models.
27//!
28//! # Forward compatibility
29//!
30//! - Unknown parent / node types: no mark validation runs (permissive).
31//! - Unknown mark types under known parents: flagged as
32//!   [`crate::atlassian::adf_schema::AdfSchemaViolation::DisallowedMark`].
33//!   `unsupportedMark` (the round-trip preservation wrapper for marks) is
34//!   accepted everywhere via the same escape-hatch convention as
35//!   `unsupportedBlock` / `unsupportedInline`.
36
37use std::collections::HashMap;
38use std::sync::LazyLock;
39
40use serde_json::Value;
41
42use crate::atlassian::adf_attr_schema::{AttrPresence, AttrSchema, AttrType};
43use crate::atlassian::adf_schema::AdfSchemaViolation;
44
45// -----------------------------------------------------------------------------
46// Mark allow-lists by context
47// -----------------------------------------------------------------------------
48
49/// Inline marks shared by most inline-content containers (paragraph,
50/// taskItem, decisionItem, caption).
51const STD_INLINE_MARKS: &[&str] = &[
52    "annotation",
53    "backgroundColor",
54    "code",
55    "em",
56    "link",
57    "strike",
58    "strong",
59    "subsup",
60    "textColor",
61    "underline",
62];
63
64/// Heading inline marks — same as STD_INLINE_MARKS minus `code` (upstream
65/// `heading` content model excludes code marks since the heading text is
66/// styled by the heading itself).
67const HEADING_INLINE_MARKS: &[&str] = &[
68    "annotation",
69    "backgroundColor",
70    "em",
71    "link",
72    "strike",
73    "strong",
74    "subsup",
75    "textColor",
76    "underline",
77];
78
79/// `codeBlock` text accepts no marks — code blocks are literal text.
80const CODE_BLOCK_INLINE_MARKS: &[&str] = &[];
81
82/// `caption` — narrower than std (no `code`, no `annotation`).
83const CAPTION_INLINE_MARKS: &[&str] = &[
84    "backgroundColor",
85    "em",
86    "link",
87    "strike",
88    "strong",
89    "subsup",
90    "textColor",
91    "underline",
92];
93
94/// Block-level marks per block node type.
95const PARAGRAPH_BLOCK_MARKS: &[&str] = &["alignment", "indentation"];
96const HEADING_BLOCK_MARKS: &[&str] = &["alignment", "indentation"];
97const TABLE_CELL_BLOCK_MARKS: &[&str] = &["backgroundColor", "border"];
98const TABLE_HEADER_BLOCK_MARKS: &[&str] = &["backgroundColor", "border"];
99
100const INLINE_MARKS_ENTRIES: &[(&str, &[&str])] = &[
101    ("caption", CAPTION_INLINE_MARKS),
102    ("codeBlock", CODE_BLOCK_INLINE_MARKS),
103    ("decisionItem", STD_INLINE_MARKS),
104    ("heading", HEADING_INLINE_MARKS),
105    ("paragraph", STD_INLINE_MARKS),
106    ("taskItem", STD_INLINE_MARKS),
107];
108
109const BLOCK_MARKS_ENTRIES: &[(&str, &[&str])] = &[
110    ("heading", HEADING_BLOCK_MARKS),
111    ("paragraph", PARAGRAPH_BLOCK_MARKS),
112    ("tableCell", TABLE_CELL_BLOCK_MARKS),
113    ("tableHeader", TABLE_HEADER_BLOCK_MARKS),
114];
115
116static INLINE_MARKS: LazyLock<HashMap<&'static str, &'static [&'static str]>> =
117    LazyLock::new(|| INLINE_MARKS_ENTRIES.iter().copied().collect());
118
119static BLOCK_MARKS: LazyLock<HashMap<&'static str, &'static [&'static str]>> =
120    LazyLock::new(|| BLOCK_MARKS_ENTRIES.iter().copied().collect());
121
122/// Inline node types whose marks are validated against the *parent*'s
123/// inline-mark allow-list (rather than the node's own block-mark
124/// allow-list). Sorted alphabetically.
125const INLINE_NODE_TYPES: &[&str] = &[
126    "date",
127    "emoji",
128    "hardBreak",
129    "inlineCard",
130    "inlineExtension",
131    "mediaInline",
132    "mention",
133    "placeholder",
134    "status",
135    "text",
136];
137
138/// Returns the allowed inline marks under an inline-content container, or
139/// `None` if the container is not registered (permissive on unknown
140/// parents).
141#[must_use]
142pub fn allowed_inline_marks(parent: &str) -> Option<&'static [&'static str]> {
143    INLINE_MARKS.get(parent).copied()
144}
145
146/// Returns the allowed block-level marks for a block node type, or `None`
147/// if the node has no registered block-mark allow-list.
148#[must_use]
149pub fn allowed_block_marks(node_type: &str) -> Option<&'static [&'static str]> {
150    BLOCK_MARKS.get(node_type).copied()
151}
152
153/// True when `node_type` is an inline node (whose marks should be checked
154/// against the parent's inline-mark allow-list, not its own block-mark
155/// allow-list).
156#[must_use]
157pub fn is_inline_node(node_type: &str) -> bool {
158    INLINE_NODE_TYPES.contains(&node_type)
159}
160
161/// True for the round-trip preservation wrapper. Accepted under any
162/// context.
163fn is_unsupported_mark(mark_type: &str) -> bool {
164    mark_type == "unsupportedMark" || mark_type == "unsupportedNodeAttribute"
165}
166
167// -----------------------------------------------------------------------------
168// Per-mark attribute schemas
169// -----------------------------------------------------------------------------
170
171const ENUM_SUBSUP_TYPE: &[&str] = &["sub", "sup"];
172const ENUM_ALIGNMENT_ALIGN: &[&str] = &["start", "end", "center", "right", "left"];
173const ENUM_BREAKOUT_MODE: &[&str] = &["wide", "full-width"];
174
175type MarkAttrEntry = (&'static str, AttrSchema);
176
177const MARK_ATTR_ENTRIES: &[MarkAttrEntry] = &[
178    // alignment — marks/alignment.ts
179    (
180        "alignment",
181        AttrSchema {
182            fields: &[(
183                "align",
184                AttrType::Enum(ENUM_ALIGNMENT_ALIGN),
185                AttrPresence::Required,
186            )],
187        },
188    ),
189    // annotation — marks/annotation.ts
190    (
191        "annotation",
192        AttrSchema {
193            fields: &[
194                ("id", AttrType::String, AttrPresence::Required),
195                ("annotationType", AttrType::String, AttrPresence::Required),
196            ],
197        },
198    ),
199    // backgroundColor — marks/backgroundColor.ts
200    // upstream: { color: hex string } (must look like #RRGGBB or #RRGGBBAA)
201    (
202        "backgroundColor",
203        AttrSchema {
204            fields: &[("color", AttrType::String, AttrPresence::Required)],
205        },
206    ),
207    // border — marks/border.ts
208    (
209        "border",
210        AttrSchema {
211            fields: &[
212                ("color", AttrType::String, AttrPresence::Required),
213                ("size", AttrType::IntRange(1, 3), AttrPresence::Required),
214            ],
215        },
216    ),
217    // breakout — marks/breakout.ts
218    (
219        "breakout",
220        AttrSchema {
221            fields: &[(
222                "mode",
223                AttrType::Enum(ENUM_BREAKOUT_MODE),
224                AttrPresence::Required,
225            )],
226        },
227    ),
228    // code — marks/code.ts (no attrs upstream)
229    ("code", AttrSchema { fields: &[] }),
230    // em — marks/em.ts (no attrs)
231    ("em", AttrSchema { fields: &[] }),
232    // indentation — marks/indentation.ts
233    (
234        "indentation",
235        AttrSchema {
236            fields: &[("level", AttrType::IntRange(1, 6), AttrPresence::Required)],
237        },
238    ),
239    // link — marks/link.ts
240    // upstream: { href: string (URI), title?: string, id?: string,
241    //             collection?: string, occurrenceKey?: string }
242    (
243        "link",
244        AttrSchema {
245            fields: &[
246                ("href", AttrType::Url, AttrPresence::Required),
247                ("title", AttrType::String, AttrPresence::Optional),
248                ("id", AttrType::String, AttrPresence::Optional),
249                ("collection", AttrType::String, AttrPresence::Optional),
250                ("occurrenceKey", AttrType::String, AttrPresence::Optional),
251            ],
252        },
253    ),
254    // strike — marks/strike.ts (no attrs)
255    ("strike", AttrSchema { fields: &[] }),
256    // strong — marks/strong.ts (no attrs)
257    ("strong", AttrSchema { fields: &[] }),
258    // subsup — marks/subsup.ts
259    (
260        "subsup",
261        AttrSchema {
262            fields: &[(
263                "type",
264                AttrType::Enum(ENUM_SUBSUP_TYPE),
265                AttrPresence::Required,
266            )],
267        },
268    ),
269    // textColor — marks/textColor.ts
270    // upstream: { color: hex string }
271    (
272        "textColor",
273        AttrSchema {
274            fields: &[("color", AttrType::String, AttrPresence::Required)],
275        },
276    ),
277    // underline — marks/underline.ts (no attrs)
278    ("underline", AttrSchema { fields: &[] }),
279];
280
281static MARK_ATTR_SCHEMAS: LazyLock<HashMap<&'static str, &'static AttrSchema>> =
282    LazyLock::new(|| {
283        MARK_ATTR_ENTRIES
284            .iter()
285            .map(|(mark_type, schema)| (*mark_type, schema))
286            .collect()
287    });
288
289/// Returns the attribute schema for a mark type, or `None` if not
290/// registered.
291///
292/// Permissive on unknown marks — they will still be flagged by the
293/// allow-list check if they appear in a context that doesn't permit
294/// them.
295#[must_use]
296pub fn mark_attr_schema(mark_type: &str) -> Option<&'static AttrSchema> {
297    MARK_ATTR_SCHEMAS.get(mark_type).copied()
298}
299
300// -----------------------------------------------------------------------------
301// Validation entry point
302// -----------------------------------------------------------------------------
303
304/// Validates the marks on a single node, appending any violations to `out`.
305///
306/// `parent_type` is the parent of `node`. `path` is the index path from
307/// the document root to `node` (the same path used by the
308/// `DisallowedChild` / `Arity` checks).
309///
310/// Mark validation is structured as:
311///
312/// 1. Determine the active allow-list:
313///     - inline node → inline-marks for `parent_type`
314///     - block node  → block-marks for `node.node_type`
315///       Unknown contexts produce no allow-list and skip the check.
316///
317/// 2. For each mark on the node:
318///     - If `mark_type` is `unsupported{Mark,NodeAttribute}`, accept it
319///       (round-trip preservation wrapper).
320///     - If the allow-list doesn't include the mark, emit
321///       `DisallowedMark`.
322///    - Validate the mark's `attrs` against `mark_attr_schema(mark_type)`,
323///      emitting `InvalidMarkAttr` per problem.
324pub fn validate_marks(
325    parent_type: &str,
326    node: &crate::atlassian::adf::AdfNode,
327    path: &[usize],
328    out: &mut Vec<AdfSchemaViolation>,
329) {
330    let Some(marks) = node.marks.as_ref() else {
331        return;
332    };
333    if marks.is_empty() {
334        return;
335    }
336
337    let node_type = node.node_type.as_str();
338    let allowed = if is_inline_node(node_type) {
339        allowed_inline_marks(parent_type)
340    } else {
341        allowed_block_marks(node_type)
342    };
343
344    for (mark_idx, mark) in marks.iter().enumerate() {
345        let mark_type = mark.mark_type.as_str();
346
347        if is_unsupported_mark(mark_type) {
348            continue;
349        }
350
351        if let Some(allowed) = allowed {
352            if !allowed.contains(&mark_type) {
353                out.push(AdfSchemaViolation::DisallowedMark {
354                    mark_type: mark_type.to_string(),
355                    parent_type: if is_inline_node(node_type) {
356                        parent_type.to_string()
357                    } else {
358                        node_type.to_string()
359                    },
360                    inline_index: if is_inline_node(node_type) {
361                        Some(*path.last().unwrap_or(&0))
362                    } else {
363                        None
364                    },
365                    path: path.to_vec(),
366                });
367                // Don't validate attrs for a mark that isn't even allowed
368                // here — the schema lookup might still succeed but the
369                // mark is structurally rejected.
370                continue;
371            }
372        }
373
374        if let Some(schema) = mark_attr_schema(mark_type) {
375            validate_mark_attrs_against(
376                schema,
377                mark_type,
378                mark.attrs.as_ref(),
379                mark_idx,
380                path,
381                out,
382            );
383        }
384    }
385}
386
387fn validate_mark_attrs_against(
388    schema: &AttrSchema,
389    mark_type: &str,
390    attrs: Option<&Value>,
391    mark_idx: usize,
392    path: &[usize],
393    out: &mut Vec<AdfSchemaViolation>,
394) {
395    // Reuse the per-node validate_attrs by calling its underlying logic
396    // through a small adapter that translates Missing/Invalid attr
397    // violations into the mark-specific variants.
398    let mut tmp: Vec<AdfSchemaViolation> = Vec::new();
399    crate::atlassian::adf_attr_schema::validate_attrs(
400        // The shared validate_attrs uses node_type as a *lookup key*. To
401        // reuse its body without lookup, pass a sentinel that has no
402        // schema and validate inline below. Easier: replicate the small
403        // loop here so we control variant emission.
404        "<__adf_mark_inline__>",
405        attrs,
406        path,
407        &mut tmp,
408    );
409    debug_assert!(
410        tmp.is_empty(),
411        "sentinel must not match a registered schema"
412    );
413
414    // Inline replication of the schema walk so we emit the mark variants.
415    let attr_obj = match attrs {
416        Some(Value::Object(map)) => Some(map),
417        Some(Value::Null) | None => None,
418        Some(_other) => {
419            for (field, _ty, presence) in schema.fields {
420                if *presence == AttrPresence::Required {
421                    out.push(AdfSchemaViolation::DisallowedMark {
422                        mark_type: mark_type.to_string(),
423                        parent_type: format!("<malformed attrs for mark '{mark_type}'>"),
424                        inline_index: Some(mark_idx),
425                        path: path.to_vec(),
426                    });
427                    let _ = field;
428                    return;
429                }
430            }
431            return;
432        }
433    };
434
435    for (field, ty, presence) in schema.fields {
436        let value = attr_obj.and_then(|m| m.get(*field));
437        let value = match value {
438            Some(Value::Null) | None => None,
439            Some(v) => Some(v),
440        };
441
442        match (value, *presence) {
443            (None, AttrPresence::Required) => {
444                out.push(AdfSchemaViolation::InvalidMarkAttr {
445                    mark_type: mark_type.to_string(),
446                    attr_name: (*field).to_string(),
447                    problem: crate::atlassian::adf_attr_schema::AttrProblem::WrongType {
448                        expected: "present",
449                    },
450                    inline_index: Some(mark_idx),
451                    path: path.to_vec(),
452                });
453            }
454            (None, AttrPresence::Optional) => {}
455            (Some(v), _) => {
456                if let Some(problem) = crate::atlassian::adf_attr_schema::check_value(ty, v) {
457                    out.push(AdfSchemaViolation::InvalidMarkAttr {
458                        mark_type: mark_type.to_string(),
459                        attr_name: (*field).to_string(),
460                        problem,
461                        inline_index: Some(mark_idx),
462                        path: path.to_vec(),
463                    });
464                }
465            }
466        }
467    }
468}
469
470#[cfg(test)]
471#[allow(clippy::unwrap_used, clippy::expect_used, clippy::needless_collect)]
472mod tests {
473    use super::*;
474    use crate::atlassian::adf::{AdfMark, AdfNode};
475
476    fn text_with_marks(text: &str, marks: Vec<AdfMark>) -> AdfNode {
477        AdfNode {
478            node_type: "text".to_string(),
479            attrs: None,
480            content: None,
481            text: Some(text.to_string()),
482            marks: Some(marks),
483            local_id: None,
484            parameters: None,
485        }
486    }
487
488    fn paragraph_with_marks(marks: Vec<AdfMark>, content: Vec<AdfNode>) -> AdfNode {
489        AdfNode {
490            node_type: "paragraph".to_string(),
491            attrs: None,
492            content: if content.is_empty() {
493                None
494            } else {
495                Some(content)
496            },
497            text: None,
498            marks: Some(marks),
499            local_id: None,
500            parameters: None,
501        }
502    }
503
504    fn mark(mark_type: &str, attrs: Option<serde_json::Value>) -> AdfMark {
505        AdfMark {
506            mark_type: mark_type.to_string(),
507            attrs,
508        }
509    }
510
511    fn run_inline(parent: &str, child: AdfNode) -> Vec<AdfSchemaViolation> {
512        let mut out = Vec::new();
513        validate_marks(parent, &child, &[0_usize], &mut out);
514        out
515    }
516
517    fn run_block(node: AdfNode) -> Vec<AdfSchemaViolation> {
518        let mut out = Vec::new();
519        validate_marks("doc", &node, &[0_usize], &mut out);
520        out
521    }
522
523    // ---- Inline marks: allow-list ---------------------------------------
524
525    #[test]
526    fn paragraph_allows_code_mark_on_text() {
527        let node = text_with_marks("hi", vec![mark("code", None)]);
528        assert!(run_inline("paragraph", node).is_empty());
529    }
530
531    #[test]
532    fn heading_rejects_code_mark_on_text() {
533        let node = text_with_marks("hi", vec![mark("code", None)]);
534        let v = run_inline("heading", node);
535        assert_eq!(v.len(), 1, "got: {v:?}");
536        match &v[0] {
537            AdfSchemaViolation::DisallowedMark {
538                mark_type,
539                parent_type,
540                ..
541            } => {
542                assert_eq!(mark_type, "code");
543                assert_eq!(parent_type, "heading");
544            }
545            other => panic!("expected DisallowedMark, got {other:?}"),
546        }
547    }
548
549    #[test]
550    fn code_block_rejects_any_mark_on_text() {
551        let node = text_with_marks("hi", vec![mark("strong", None)]);
552        let v = run_inline("codeBlock", node);
553        assert_eq!(v.len(), 1);
554    }
555
556    #[test]
557    fn unknown_parent_skips_mark_validation() {
558        let node = text_with_marks("hi", vec![mark("madeUp", None)]);
559        assert!(run_inline("madeUpParent", node).is_empty());
560    }
561
562    #[test]
563    fn unsupported_mark_accepted_anywhere() {
564        let node = text_with_marks(
565            "hi",
566            vec![
567                mark("unsupportedMark", None),
568                mark("unsupportedNodeAttribute", None),
569            ],
570        );
571        assert!(run_inline("heading", node).is_empty());
572    }
573
574    // ---- Block marks ----------------------------------------------------
575
576    #[test]
577    fn paragraph_block_allows_alignment() {
578        let node = paragraph_with_marks(
579            vec![mark(
580                "alignment",
581                Some(serde_json::json!({"align": "center"})),
582            )],
583            vec![AdfNode::text("x")],
584        );
585        assert!(run_block(node).is_empty());
586    }
587
588    #[test]
589    fn paragraph_block_rejects_border() {
590        // border is a tableCell-only block mark.
591        let node = paragraph_with_marks(
592            vec![mark(
593                "border",
594                Some(serde_json::json!({"color": "#ff0000", "size": 1})),
595            )],
596            vec![AdfNode::text("x")],
597        );
598        let v = run_block(node);
599        let disallowed: Vec<_> = v
600            .iter()
601            .filter(|v| matches!(v, AdfSchemaViolation::DisallowedMark { .. }))
602            .collect();
603        assert_eq!(disallowed.len(), 1, "got: {v:?}");
604    }
605
606    #[test]
607    fn table_cell_allows_border() {
608        let cell = AdfNode {
609            node_type: "tableCell".to_string(),
610            attrs: None,
611            content: None,
612            text: None,
613            marks: Some(vec![mark(
614                "border",
615                Some(serde_json::json!({"color": "#ff0000", "size": 2})),
616            )]),
617            local_id: None,
618            parameters: None,
619        };
620        assert!(run_block(cell).is_empty());
621    }
622
623    // ---- Mark attr validation -------------------------------------------
624
625    #[test]
626    fn link_mark_with_valid_href_validates() {
627        let node = text_with_marks(
628            "hi",
629            vec![mark(
630                "link",
631                Some(serde_json::json!({"href": "https://x.com"})),
632            )],
633        );
634        assert!(run_inline("paragraph", node).is_empty());
635    }
636
637    #[test]
638    fn link_mark_with_invalid_href_flagged() {
639        let node = text_with_marks(
640            "hi",
641            vec![mark("link", Some(serde_json::json!({"href": "not a url"})))],
642        );
643        let v = run_inline("paragraph", node);
644        let invalid: Vec<_> = v
645            .iter()
646            .filter(|v| matches!(v, AdfSchemaViolation::InvalidMarkAttr { .. }))
647            .collect();
648        assert_eq!(invalid.len(), 1, "got: {v:?}");
649    }
650
651    #[test]
652    fn link_mark_missing_href_flagged() {
653        let node = text_with_marks("hi", vec![mark("link", Some(serde_json::json!({})))]);
654        let v = run_inline("paragraph", node);
655        let invalid: Vec<_> = v
656            .iter()
657            .filter(|v| matches!(v, AdfSchemaViolation::InvalidMarkAttr { .. }))
658            .collect();
659        assert_eq!(invalid.len(), 1);
660    }
661
662    #[test]
663    fn subsup_known_type_validates() {
664        for t in ["sub", "sup"] {
665            let node = text_with_marks(
666                "hi",
667                vec![mark("subsup", Some(serde_json::json!({"type": t})))],
668            );
669            assert!(run_inline("paragraph", node).is_empty());
670        }
671    }
672
673    #[test]
674    fn subsup_unknown_type_flagged() {
675        let node = text_with_marks(
676            "hi",
677            vec![mark("subsup", Some(serde_json::json!({"type": "side"})))],
678        );
679        let v = run_inline("paragraph", node);
680        let invalid: Vec<_> = v
681            .iter()
682            .filter(|v| matches!(v, AdfSchemaViolation::InvalidMarkAttr { .. }))
683            .collect();
684        assert_eq!(invalid.len(), 1);
685    }
686
687    #[test]
688    fn indentation_level_in_range() {
689        let node = paragraph_with_marks(
690            vec![mark("indentation", Some(serde_json::json!({"level": 3})))],
691            vec![AdfNode::text("x")],
692        );
693        assert!(run_block(node).is_empty());
694    }
695
696    #[test]
697    fn indentation_level_out_of_range_flagged() {
698        let node = paragraph_with_marks(
699            vec![mark("indentation", Some(serde_json::json!({"level": 10})))],
700            vec![AdfNode::text("x")],
701        );
702        let v = run_block(node);
703        let invalid: Vec<_> = v
704            .iter()
705            .filter(|v| matches!(v, AdfSchemaViolation::InvalidMarkAttr { .. }))
706            .collect();
707        assert_eq!(invalid.len(), 1);
708    }
709
710    #[test]
711    fn border_with_size_too_large_flagged() {
712        let cell = AdfNode {
713            node_type: "tableCell".to_string(),
714            attrs: None,
715            content: None,
716            text: None,
717            marks: Some(vec![mark(
718                "border",
719                Some(serde_json::json!({"color": "#ff0000", "size": 5})),
720            )]),
721            local_id: None,
722            parameters: None,
723        };
724        let v = run_block(cell);
725        let invalid: Vec<_> = v
726            .iter()
727            .filter(|v| matches!(v, AdfSchemaViolation::InvalidMarkAttr { .. }))
728            .collect();
729        assert_eq!(invalid.len(), 1);
730    }
731
732    #[test]
733    fn empty_marks_array_no_violations() {
734        let node = text_with_marks("hi", vec![]);
735        assert!(run_inline("paragraph", node).is_empty());
736    }
737
738    #[test]
739    fn no_marks_field_no_violations() {
740        let node = AdfNode::text("hi");
741        assert!(run_inline("paragraph", node).is_empty());
742    }
743
744    // ── malformed attrs path on a mark ─────────────────────────────────
745
746    #[test]
747    fn link_mark_with_array_attrs_flagged_as_disallowed_mark() {
748        // attrs is present but not an object (array). The link schema has
749        // a required `href`, so the malformed-attrs branch fires and emits
750        // a DisallowedMark with a sentinel parent_type marker.
751        let node = text_with_marks(
752            "click",
753            vec![mark("link", Some(serde_json::json!([1, 2, 3])))],
754        );
755        let v = run_inline("paragraph", node);
756        let disallowed: Vec<_> = v
757            .iter()
758            .filter(|v| matches!(v, AdfSchemaViolation::DisallowedMark { .. }))
759            .collect();
760        assert_eq!(disallowed.len(), 1, "got: {v:?}");
761        match disallowed[0] {
762            AdfSchemaViolation::DisallowedMark {
763                mark_type,
764                parent_type,
765                ..
766            } => {
767                assert_eq!(mark_type, "link");
768                assert!(
769                    parent_type.contains("malformed attrs"),
770                    "expected malformed-attrs sentinel, got: {parent_type}"
771                );
772            }
773            other => panic!("expected DisallowedMark, got {other:?}"),
774        }
775    }
776
777    #[test]
778    fn code_mark_with_array_attrs_no_violation() {
779        // `code` mark schema has no required fields, so the malformed-attrs
780        // branch should fall through without emitting anything (covers the
781        // bare `return;` arm at the bottom of the malformed-attrs match).
782        let node = text_with_marks("x", vec![mark("code", Some(serde_json::json!([1, 2, 3])))]);
783        assert!(run_inline("paragraph", node).is_empty());
784    }
785
786    // ── inline node under a parent without an inline-mark allow-list ──
787
788    #[test]
789    fn inline_node_under_unknown_parent_skips_mark_check() {
790        // Parent has no inline-mark allow-list, so the mark is neither
791        // accepted-by-allow-list nor flagged. Validates that the
792        // `if let Some(allowed) = allowed { ... }` guard short-circuits
793        // cleanly when `allowed` is `None`, but the per-mark attr schema
794        // still runs (link.href validation here).
795        let node = text_with_marks(
796            "x",
797            vec![mark("link", Some(serde_json::json!({"href": "not a url"})))],
798        );
799        let v = run_inline("madeUpParent", node);
800        // No DisallowedMark (no allow-list to violate).
801        assert!(v
802            .iter()
803            .all(|v| !matches!(v, AdfSchemaViolation::DisallowedMark { .. })));
804        // But the mark-attr validation still fires.
805        assert!(v
806            .iter()
807            .any(|v| matches!(v, AdfSchemaViolation::InvalidMarkAttr { .. })));
808    }
809}