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
100// -----------------------------------------------------------------------------
101// Inline-mark combination groups (issue #1047)
102// -----------------------------------------------------------------------------
103
104/// Marks permitted together on a *monospace* text node, transcribed from the
105/// `code_inline_node` variant in the pinned upstream schema
106/// (`assets/adf-schema/full.json`).
107const CODE_INLINE_MARK_GROUP: &[&str] = &["annotation", "code", "link"];
108
109/// Marks permitted together on a *styled* (non-monospace) text node,
110/// transcribed from the `formatted_text_inline_node` variant.
111const FORMATTED_INLINE_MARK_GROUP: &[&str] = &[
112    "annotation",
113    "backgroundColor",
114    "em",
115    "link",
116    "strike",
117    "strong",
118    "subsup",
119    "textColor",
120    "underline",
121];
122
123/// The mutually-exclusive inline-mark groups. A text node's marks must all
124/// fit within a *single* group — the upstream schema offers a text node as an
125/// `anyOf` over these variants, so mixing marks from different groups (most
126/// commonly `code` with any styling mark) is what the API rejects as opaque
127/// `INVALID_INPUT`.
128///
129/// Note: `link` and `annotation` appear in both groups, so they never
130/// conflict with anything. `code` appears only in the code group, which is
131/// why it combines with nothing but `link`/`annotation`.
132const INLINE_MARK_GROUPS: &[&[&str]] = &[CODE_INLINE_MARK_GROUP, FORMATTED_INLINE_MARK_GROUP];
133
134/// True when some single group contains both marks (i.e. they may coexist on
135/// one text node). Marks that belong to no group are treated as
136/// non-conflicting here — their legality is decided by the allow-list check,
137/// not the combination check.
138fn marks_may_coexist(a: &str, b: &str) -> bool {
139    let in_a_group = |m: &str| INLINE_MARK_GROUPS.iter().any(|g| g.contains(&m));
140    if !in_a_group(a) || !in_a_group(b) {
141        return true;
142    }
143    INLINE_MARK_GROUPS
144        .iter()
145        .any(|g| g.contains(&a) && g.contains(&b))
146}
147
148const INLINE_MARKS_ENTRIES: &[(&str, &[&str])] = &[
149    ("caption", CAPTION_INLINE_MARKS),
150    ("codeBlock", CODE_BLOCK_INLINE_MARKS),
151    ("decisionItem", STD_INLINE_MARKS),
152    ("heading", HEADING_INLINE_MARKS),
153    ("paragraph", STD_INLINE_MARKS),
154    ("taskItem", STD_INLINE_MARKS),
155];
156
157const BLOCK_MARKS_ENTRIES: &[(&str, &[&str])] = &[
158    ("heading", HEADING_BLOCK_MARKS),
159    ("paragraph", PARAGRAPH_BLOCK_MARKS),
160    ("tableCell", TABLE_CELL_BLOCK_MARKS),
161    ("tableHeader", TABLE_HEADER_BLOCK_MARKS),
162];
163
164static INLINE_MARKS: LazyLock<HashMap<&'static str, &'static [&'static str]>> =
165    LazyLock::new(|| INLINE_MARKS_ENTRIES.iter().copied().collect());
166
167static BLOCK_MARKS: LazyLock<HashMap<&'static str, &'static [&'static str]>> =
168    LazyLock::new(|| BLOCK_MARKS_ENTRIES.iter().copied().collect());
169
170/// Inline node types whose marks are validated against the *parent*'s
171/// inline-mark allow-list (rather than the node's own block-mark
172/// allow-list). Sorted alphabetically.
173const INLINE_NODE_TYPES: &[&str] = &[
174    "date",
175    "emoji",
176    "hardBreak",
177    "inlineCard",
178    "inlineExtension",
179    "mediaInline",
180    "mention",
181    "placeholder",
182    "status",
183    "text",
184];
185
186/// Returns the allowed inline marks under an inline-content container, or
187/// `None` if the container is not registered (permissive on unknown
188/// parents).
189#[must_use]
190pub fn allowed_inline_marks(parent: &str) -> Option<&'static [&'static str]> {
191    INLINE_MARKS.get(parent).copied()
192}
193
194/// Returns the allowed block-level marks for a block node type, or `None`
195/// if the node has no registered block-mark allow-list.
196#[must_use]
197pub fn allowed_block_marks(node_type: &str) -> Option<&'static [&'static str]> {
198    BLOCK_MARKS.get(node_type).copied()
199}
200
201/// True when `node_type` is an inline node (whose marks should be checked
202/// against the parent's inline-mark allow-list, not its own block-mark
203/// allow-list).
204#[must_use]
205pub fn is_inline_node(node_type: &str) -> bool {
206    INLINE_NODE_TYPES.contains(&node_type)
207}
208
209/// True for the round-trip preservation wrapper. Accepted under any
210/// context.
211fn is_unsupported_mark(mark_type: &str) -> bool {
212    mark_type == "unsupportedMark" || mark_type == "unsupportedNodeAttribute"
213}
214
215// -----------------------------------------------------------------------------
216// Per-mark attribute schemas
217// -----------------------------------------------------------------------------
218
219const ENUM_SUBSUP_TYPE: &[&str] = &["sub", "sup"];
220const ENUM_ALIGNMENT_ALIGN: &[&str] = &["start", "end", "center", "right", "left"];
221const ENUM_BREAKOUT_MODE: &[&str] = &["wide", "full-width"];
222
223type MarkAttrEntry = (&'static str, AttrSchema);
224
225const MARK_ATTR_ENTRIES: &[MarkAttrEntry] = &[
226    // alignment — marks/alignment.ts
227    (
228        "alignment",
229        AttrSchema {
230            fields: &[(
231                "align",
232                AttrType::Enum(ENUM_ALIGNMENT_ALIGN),
233                AttrPresence::Required,
234            )],
235        },
236    ),
237    // annotation — marks/annotation.ts
238    (
239        "annotation",
240        AttrSchema {
241            fields: &[
242                ("id", AttrType::String, AttrPresence::Required),
243                ("annotationType", AttrType::String, AttrPresence::Required),
244            ],
245        },
246    ),
247    // backgroundColor — marks/backgroundColor.ts
248    // upstream: { color: hex string } (must look like #RRGGBB or #RRGGBBAA)
249    (
250        "backgroundColor",
251        AttrSchema {
252            fields: &[("color", AttrType::String, AttrPresence::Required)],
253        },
254    ),
255    // border — marks/border.ts
256    (
257        "border",
258        AttrSchema {
259            fields: &[
260                ("color", AttrType::String, AttrPresence::Required),
261                ("size", AttrType::IntRange(1, 3), AttrPresence::Required),
262            ],
263        },
264    ),
265    // breakout — marks/breakout.ts
266    (
267        "breakout",
268        AttrSchema {
269            fields: &[(
270                "mode",
271                AttrType::Enum(ENUM_BREAKOUT_MODE),
272                AttrPresence::Required,
273            )],
274        },
275    ),
276    // code — marks/code.ts (no attrs upstream)
277    ("code", AttrSchema { fields: &[] }),
278    // em — marks/em.ts (no attrs)
279    ("em", AttrSchema { fields: &[] }),
280    // indentation — marks/indentation.ts
281    (
282        "indentation",
283        AttrSchema {
284            fields: &[("level", AttrType::IntRange(1, 6), AttrPresence::Required)],
285        },
286    ),
287    // link — marks/link.ts
288    // upstream: { href: string (URI), title?: string, id?: string,
289    //             collection?: string, occurrenceKey?: string }
290    (
291        "link",
292        AttrSchema {
293            fields: &[
294                ("href", AttrType::Url, AttrPresence::Required),
295                ("title", AttrType::String, AttrPresence::Optional),
296                ("id", AttrType::String, AttrPresence::Optional),
297                ("collection", AttrType::String, AttrPresence::Optional),
298                ("occurrenceKey", AttrType::String, AttrPresence::Optional),
299            ],
300        },
301    ),
302    // strike — marks/strike.ts (no attrs)
303    ("strike", AttrSchema { fields: &[] }),
304    // strong — marks/strong.ts (no attrs)
305    ("strong", AttrSchema { fields: &[] }),
306    // subsup — marks/subsup.ts
307    (
308        "subsup",
309        AttrSchema {
310            fields: &[(
311                "type",
312                AttrType::Enum(ENUM_SUBSUP_TYPE),
313                AttrPresence::Required,
314            )],
315        },
316    ),
317    // textColor — marks/textColor.ts
318    // upstream: { color: hex string }
319    (
320        "textColor",
321        AttrSchema {
322            fields: &[("color", AttrType::String, AttrPresence::Required)],
323        },
324    ),
325    // underline — marks/underline.ts (no attrs)
326    ("underline", AttrSchema { fields: &[] }),
327];
328
329static MARK_ATTR_SCHEMAS: LazyLock<HashMap<&'static str, &'static AttrSchema>> =
330    LazyLock::new(|| {
331        MARK_ATTR_ENTRIES
332            .iter()
333            .map(|(mark_type, schema)| (*mark_type, schema))
334            .collect()
335    });
336
337/// Returns the attribute schema for a mark type, or `None` if not
338/// registered.
339///
340/// Permissive on unknown marks — they will still be flagged by the
341/// allow-list check if they appear in a context that doesn't permit
342/// them.
343#[must_use]
344pub fn mark_attr_schema(mark_type: &str) -> Option<&'static AttrSchema> {
345    MARK_ATTR_SCHEMAS.get(mark_type).copied()
346}
347
348// -----------------------------------------------------------------------------
349// Validation entry point
350// -----------------------------------------------------------------------------
351
352/// Validates the marks on a single node, appending any violations to `out`.
353///
354/// `parent_type` is the parent of `node`. `path` is the index path from
355/// the document root to `node` (the same path used by the
356/// `DisallowedChild` / `Arity` checks).
357///
358/// Mark validation is structured as:
359///
360/// 1. Determine the active allow-list:
361///     - inline node → inline-marks for `parent_type`
362///     - block node  → block-marks for `node.node_type`
363///       Unknown contexts produce no allow-list and skip the check.
364///
365/// 2. For each mark on the node:
366///     - If `mark_type` is `unsupported{Mark,NodeAttribute}`, accept it
367///       (round-trip preservation wrapper).
368///     - If the allow-list doesn't include the mark, emit
369///       `DisallowedMark`.
370///    - Validate the mark's `attrs` against `mark_attr_schema(mark_type)`,
371///      emitting `InvalidMarkAttr` per problem.
372pub fn validate_marks(
373    parent_type: &str,
374    node: &crate::atlassian::adf::AdfNode,
375    path: &[usize],
376    out: &mut Vec<AdfSchemaViolation>,
377) {
378    let Some(marks) = node.marks.as_ref() else {
379        return;
380    };
381    if marks.is_empty() {
382        return;
383    }
384
385    let node_type = node.node_type.as_str();
386    let allowed = if is_inline_node(node_type) {
387        allowed_inline_marks(parent_type)
388    } else {
389        allowed_block_marks(node_type)
390    };
391
392    for (mark_idx, mark) in marks.iter().enumerate() {
393        let mark_type = mark.mark_type.as_str();
394
395        if is_unsupported_mark(mark_type) {
396            continue;
397        }
398
399        if let Some(allowed) = allowed {
400            if !allowed.contains(&mark_type) {
401                out.push(AdfSchemaViolation::DisallowedMark {
402                    mark_type: mark_type.to_string(),
403                    parent_type: if is_inline_node(node_type) {
404                        parent_type.to_string()
405                    } else {
406                        node_type.to_string()
407                    },
408                    inline_index: if is_inline_node(node_type) {
409                        Some(*path.last().unwrap_or(&0))
410                    } else {
411                        None
412                    },
413                    path: path.to_vec(),
414                });
415                // Don't validate attrs for a mark that isn't even allowed
416                // here — the schema lookup might still succeed but the
417                // mark is structurally rejected.
418                continue;
419            }
420        }
421
422        if let Some(schema) = mark_attr_schema(mark_type) {
423            validate_mark_attrs_against(
424                schema,
425                mark_type,
426                mark.attrs.as_ref(),
427                mark_idx,
428                path,
429                out,
430            );
431        }
432    }
433
434    // Cross-mark combination check (issue #1047). Only inline text marks are
435    // partitioned into mutually-exclusive groups; block marks have no such
436    // constraint, so restrict the check to inline nodes.
437    if is_inline_node(node_type) {
438        check_inline_mark_combination(parent_type, marks, path, out);
439    }
440}
441
442/// Emits a single [`AdfSchemaViolation::ForbiddenMarkCombination`] for the
443/// first pair of marks on `node` that cannot coexist on one text node per
444/// [`INLINE_MARK_GROUPS`]. Reporting only the first pair keeps the diagnosis
445/// focused (the same "surface the first violation" convention the rest of the
446/// validator follows).
447fn check_inline_mark_combination(
448    parent_type: &str,
449    marks: &[crate::atlassian::adf::AdfMark],
450    path: &[usize],
451    out: &mut Vec<AdfSchemaViolation>,
452) {
453    // Distinct mark types in first-seen order; skip the round-trip wrappers.
454    let mut seen: Vec<&str> = Vec::new();
455    for mark in marks {
456        let m = mark.mark_type.as_str();
457        if is_unsupported_mark(m) || seen.contains(&m) {
458            continue;
459        }
460        seen.push(m);
461    }
462
463    for i in 0..seen.len() {
464        for j in (i + 1)..seen.len() {
465            if !marks_may_coexist(seen[i], seen[j]) {
466                out.push(AdfSchemaViolation::ForbiddenMarkCombination {
467                    mark_type: seen[i].to_string(),
468                    conflicts_with: seen[j].to_string(),
469                    parent_type: parent_type.to_string(),
470                    inline_index: Some(*path.last().unwrap_or(&0)),
471                    path: path.to_vec(),
472                });
473                return;
474            }
475        }
476    }
477}
478
479fn validate_mark_attrs_against(
480    schema: &AttrSchema,
481    mark_type: &str,
482    attrs: Option<&Value>,
483    mark_idx: usize,
484    path: &[usize],
485    out: &mut Vec<AdfSchemaViolation>,
486) {
487    // Reuse the per-node validate_attrs by calling its underlying logic
488    // through a small adapter that translates Missing/Invalid attr
489    // violations into the mark-specific variants.
490    let mut tmp: Vec<AdfSchemaViolation> = Vec::new();
491    crate::atlassian::adf_attr_schema::validate_attrs(
492        // The shared validate_attrs uses node_type as a *lookup key*. To
493        // reuse its body without lookup, pass a sentinel that has no
494        // schema and validate inline below. Easier: replicate the small
495        // loop here so we control variant emission.
496        "<__adf_mark_inline__>",
497        attrs,
498        path,
499        &mut tmp,
500    );
501    debug_assert!(
502        tmp.is_empty(),
503        "sentinel must not match a registered schema"
504    );
505
506    // Inline replication of the schema walk so we emit the mark variants.
507    let attr_obj = match attrs {
508        Some(Value::Object(map)) => Some(map),
509        Some(Value::Null) | None => None,
510        Some(_other) => {
511            for (field, _ty, presence) in schema.fields {
512                if *presence == AttrPresence::Required {
513                    out.push(AdfSchemaViolation::DisallowedMark {
514                        mark_type: mark_type.to_string(),
515                        parent_type: format!("<malformed attrs for mark '{mark_type}'>"),
516                        inline_index: Some(mark_idx),
517                        path: path.to_vec(),
518                    });
519                    let _ = field;
520                    return;
521                }
522            }
523            return;
524        }
525    };
526
527    for (field, ty, presence) in schema.fields {
528        let value = attr_obj.and_then(|m| m.get(*field));
529        let value = match value {
530            Some(Value::Null) | None => None,
531            Some(v) => Some(v),
532        };
533
534        match (value, *presence) {
535            (None, AttrPresence::Required) => {
536                out.push(AdfSchemaViolation::InvalidMarkAttr {
537                    mark_type: mark_type.to_string(),
538                    attr_name: (*field).to_string(),
539                    problem: crate::atlassian::adf_attr_schema::AttrProblem::WrongType {
540                        expected: "present",
541                    },
542                    inline_index: Some(mark_idx),
543                    path: path.to_vec(),
544                });
545            }
546            (None, AttrPresence::Optional) => {}
547            (Some(v), _) => {
548                if let Some(problem) = crate::atlassian::adf_attr_schema::check_value(ty, v) {
549                    out.push(AdfSchemaViolation::InvalidMarkAttr {
550                        mark_type: mark_type.to_string(),
551                        attr_name: (*field).to_string(),
552                        problem,
553                        inline_index: Some(mark_idx),
554                        path: path.to_vec(),
555                    });
556                }
557            }
558        }
559    }
560}
561
562#[cfg(test)]
563#[allow(clippy::unwrap_used, clippy::expect_used, clippy::needless_collect)]
564mod tests {
565    use super::*;
566    use crate::atlassian::adf::{AdfMark, AdfNode};
567
568    fn text_with_marks(text: &str, marks: Vec<AdfMark>) -> AdfNode {
569        AdfNode {
570            node_type: "text".to_string(),
571            attrs: None,
572            content: None,
573            text: Some(text.to_string()),
574            marks: Some(marks),
575            local_id: None,
576            parameters: None,
577        }
578    }
579
580    fn paragraph_with_marks(marks: Vec<AdfMark>, content: Vec<AdfNode>) -> AdfNode {
581        AdfNode {
582            node_type: "paragraph".to_string(),
583            attrs: None,
584            content: if content.is_empty() {
585                None
586            } else {
587                Some(content)
588            },
589            text: None,
590            marks: Some(marks),
591            local_id: None,
592            parameters: None,
593        }
594    }
595
596    fn mark(mark_type: &str, attrs: Option<serde_json::Value>) -> AdfMark {
597        AdfMark {
598            mark_type: mark_type.to_string(),
599            attrs,
600        }
601    }
602
603    fn run_inline(parent: &str, child: AdfNode) -> Vec<AdfSchemaViolation> {
604        let mut out = Vec::new();
605        validate_marks(parent, &child, &[0_usize], &mut out);
606        out
607    }
608
609    fn run_block(node: AdfNode) -> Vec<AdfSchemaViolation> {
610        let mut out = Vec::new();
611        validate_marks("doc", &node, &[0_usize], &mut out);
612        out
613    }
614
615    // ---- Inline marks: allow-list ---------------------------------------
616
617    #[test]
618    fn paragraph_allows_code_mark_on_text() {
619        let node = text_with_marks("hi", vec![mark("code", None)]);
620        assert!(run_inline("paragraph", node).is_empty());
621    }
622
623    #[test]
624    fn heading_rejects_code_mark_on_text() {
625        let node = text_with_marks("hi", vec![mark("code", None)]);
626        let v = run_inline("heading", node);
627        assert_eq!(v.len(), 1, "got: {v:?}");
628        match &v[0] {
629            AdfSchemaViolation::DisallowedMark {
630                mark_type,
631                parent_type,
632                ..
633            } => {
634                assert_eq!(mark_type, "code");
635                assert_eq!(parent_type, "heading");
636            }
637            other => panic!("expected DisallowedMark, got {other:?}"),
638        }
639    }
640
641    #[test]
642    fn code_block_rejects_any_mark_on_text() {
643        let node = text_with_marks("hi", vec![mark("strong", None)]);
644        let v = run_inline("codeBlock", node);
645        assert_eq!(v.len(), 1);
646    }
647
648    #[test]
649    fn unknown_parent_skips_mark_validation() {
650        let node = text_with_marks("hi", vec![mark("madeUp", None)]);
651        assert!(run_inline("madeUpParent", node).is_empty());
652    }
653
654    #[test]
655    fn unsupported_mark_accepted_anywhere() {
656        let node = text_with_marks(
657            "hi",
658            vec![
659                mark("unsupportedMark", None),
660                mark("unsupportedNodeAttribute", None),
661            ],
662        );
663        assert!(run_inline("heading", node).is_empty());
664    }
665
666    // ---- Block marks ----------------------------------------------------
667
668    #[test]
669    fn paragraph_block_allows_alignment() {
670        let node = paragraph_with_marks(
671            vec![mark(
672                "alignment",
673                Some(serde_json::json!({"align": "center"})),
674            )],
675            vec![AdfNode::text("x")],
676        );
677        assert!(run_block(node).is_empty());
678    }
679
680    #[test]
681    fn paragraph_block_rejects_border() {
682        // border is a tableCell-only block mark.
683        let node = paragraph_with_marks(
684            vec![mark(
685                "border",
686                Some(serde_json::json!({"color": "#ff0000", "size": 1})),
687            )],
688            vec![AdfNode::text("x")],
689        );
690        let v = run_block(node);
691        let disallowed: Vec<_> = v
692            .iter()
693            .filter(|v| matches!(v, AdfSchemaViolation::DisallowedMark { .. }))
694            .collect();
695        assert_eq!(disallowed.len(), 1, "got: {v:?}");
696    }
697
698    #[test]
699    fn table_cell_allows_border() {
700        let cell = AdfNode {
701            node_type: "tableCell".to_string(),
702            attrs: None,
703            content: None,
704            text: None,
705            marks: Some(vec![mark(
706                "border",
707                Some(serde_json::json!({"color": "#ff0000", "size": 2})),
708            )]),
709            local_id: None,
710            parameters: None,
711        };
712        assert!(run_block(cell).is_empty());
713    }
714
715    // ---- Mark attr validation -------------------------------------------
716
717    #[test]
718    fn link_mark_with_valid_href_validates() {
719        let node = text_with_marks(
720            "hi",
721            vec![mark(
722                "link",
723                Some(serde_json::json!({"href": "https://x.com"})),
724            )],
725        );
726        assert!(run_inline("paragraph", node).is_empty());
727    }
728
729    #[test]
730    fn link_mark_with_invalid_href_flagged() {
731        let node = text_with_marks(
732            "hi",
733            vec![mark("link", Some(serde_json::json!({"href": "not a url"})))],
734        );
735        let v = run_inline("paragraph", node);
736        let invalid: Vec<_> = v
737            .iter()
738            .filter(|v| matches!(v, AdfSchemaViolation::InvalidMarkAttr { .. }))
739            .collect();
740        assert_eq!(invalid.len(), 1, "got: {v:?}");
741    }
742
743    #[test]
744    fn link_mark_missing_href_flagged() {
745        let node = text_with_marks("hi", vec![mark("link", Some(serde_json::json!({})))]);
746        let v = run_inline("paragraph", node);
747        let invalid: Vec<_> = v
748            .iter()
749            .filter(|v| matches!(v, AdfSchemaViolation::InvalidMarkAttr { .. }))
750            .collect();
751        assert_eq!(invalid.len(), 1);
752    }
753
754    #[test]
755    fn subsup_known_type_validates() {
756        for t in ["sub", "sup"] {
757            let node = text_with_marks(
758                "hi",
759                vec![mark("subsup", Some(serde_json::json!({"type": t})))],
760            );
761            assert!(run_inline("paragraph", node).is_empty());
762        }
763    }
764
765    #[test]
766    fn subsup_unknown_type_flagged() {
767        let node = text_with_marks(
768            "hi",
769            vec![mark("subsup", Some(serde_json::json!({"type": "side"})))],
770        );
771        let v = run_inline("paragraph", node);
772        let invalid: Vec<_> = v
773            .iter()
774            .filter(|v| matches!(v, AdfSchemaViolation::InvalidMarkAttr { .. }))
775            .collect();
776        assert_eq!(invalid.len(), 1);
777    }
778
779    #[test]
780    fn indentation_level_in_range() {
781        let node = paragraph_with_marks(
782            vec![mark("indentation", Some(serde_json::json!({"level": 3})))],
783            vec![AdfNode::text("x")],
784        );
785        assert!(run_block(node).is_empty());
786    }
787
788    #[test]
789    fn indentation_level_out_of_range_flagged() {
790        let node = paragraph_with_marks(
791            vec![mark("indentation", Some(serde_json::json!({"level": 10})))],
792            vec![AdfNode::text("x")],
793        );
794        let v = run_block(node);
795        let invalid: Vec<_> = v
796            .iter()
797            .filter(|v| matches!(v, AdfSchemaViolation::InvalidMarkAttr { .. }))
798            .collect();
799        assert_eq!(invalid.len(), 1);
800    }
801
802    #[test]
803    fn border_with_size_too_large_flagged() {
804        let cell = AdfNode {
805            node_type: "tableCell".to_string(),
806            attrs: None,
807            content: None,
808            text: None,
809            marks: Some(vec![mark(
810                "border",
811                Some(serde_json::json!({"color": "#ff0000", "size": 5})),
812            )]),
813            local_id: None,
814            parameters: None,
815        };
816        let v = run_block(cell);
817        let invalid: Vec<_> = v
818            .iter()
819            .filter(|v| matches!(v, AdfSchemaViolation::InvalidMarkAttr { .. }))
820            .collect();
821        assert_eq!(invalid.len(), 1);
822    }
823
824    #[test]
825    fn empty_marks_array_no_violations() {
826        let node = text_with_marks("hi", vec![]);
827        assert!(run_inline("paragraph", node).is_empty());
828    }
829
830    #[test]
831    fn no_marks_field_no_violations() {
832        let node = AdfNode::text("hi");
833        assert!(run_inline("paragraph", node).is_empty());
834    }
835
836    // ── malformed attrs path on a mark ─────────────────────────────────
837
838    #[test]
839    fn link_mark_with_array_attrs_flagged_as_disallowed_mark() {
840        // attrs is present but not an object (array). The link schema has
841        // a required `href`, so the malformed-attrs branch fires and emits
842        // a DisallowedMark with a sentinel parent_type marker.
843        let node = text_with_marks(
844            "click",
845            vec![mark("link", Some(serde_json::json!([1, 2, 3])))],
846        );
847        let v = run_inline("paragraph", node);
848        let disallowed: Vec<_> = v
849            .iter()
850            .filter(|v| matches!(v, AdfSchemaViolation::DisallowedMark { .. }))
851            .collect();
852        assert_eq!(disallowed.len(), 1, "got: {v:?}");
853        match disallowed[0] {
854            AdfSchemaViolation::DisallowedMark {
855                mark_type,
856                parent_type,
857                ..
858            } => {
859                assert_eq!(mark_type, "link");
860                assert!(
861                    parent_type.contains("malformed attrs"),
862                    "expected malformed-attrs sentinel, got: {parent_type}"
863                );
864            }
865            other => panic!("expected DisallowedMark, got {other:?}"),
866        }
867    }
868
869    #[test]
870    fn code_mark_with_array_attrs_no_violation() {
871        // `code` mark schema has no required fields, so the malformed-attrs
872        // branch should fall through without emitting anything (covers the
873        // bare `return;` arm at the bottom of the malformed-attrs match).
874        let node = text_with_marks("x", vec![mark("code", Some(serde_json::json!([1, 2, 3])))]);
875        assert!(run_inline("paragraph", node).is_empty());
876    }
877
878    // ── inline mark combinations (issue #1047) ────────────────────────
879
880    fn combos(v: &[AdfSchemaViolation]) -> Vec<(&str, &str)> {
881        v.iter()
882            .filter_map(|x| match x {
883                AdfSchemaViolation::ForbiddenMarkCombination {
884                    mark_type,
885                    conflicts_with,
886                    ..
887                } => Some((mark_type.as_str(), conflicts_with.as_str())),
888                _ => None,
889            })
890            .collect()
891    }
892
893    #[test]
894    fn strong_plus_code_is_forbidden() {
895        // The issue's repeatable trigger: `**`text`**` → strong + code.
896        let node = text_with_marks("hi", vec![mark("strong", None), mark("code", None)]);
897        let v = run_inline("paragraph", node);
898        assert_eq!(combos(&v), vec![("strong", "code")], "got: {v:?}");
899    }
900
901    #[test]
902    fn code_plus_background_color_is_forbidden() {
903        let node = text_with_marks(
904            "hi",
905            vec![
906                mark("code", None),
907                mark(
908                    "backgroundColor",
909                    Some(serde_json::json!({"color": "#ff0000"})),
910                ),
911            ],
912        );
913        let v = run_inline("paragraph", node);
914        assert_eq!(combos(&v), vec![("code", "backgroundColor")], "got: {v:?}");
915    }
916
917    #[test]
918    fn code_plus_link_is_allowed() {
919        // `link` and `code` share the code-inline group, so a monospace link
920        // is valid.
921        let node = text_with_marks(
922            "hi",
923            vec![
924                mark("code", None),
925                mark("link", Some(serde_json::json!({"href": "https://x.com"}))),
926            ],
927        );
928        let v = run_inline("paragraph", node);
929        assert!(combos(&v).is_empty(), "got: {v:?}");
930    }
931
932    #[test]
933    fn code_plus_annotation_is_allowed() {
934        let node = text_with_marks(
935            "hi",
936            vec![
937                mark("code", None),
938                mark(
939                    "annotation",
940                    Some(serde_json::json!({"id": "a1", "annotationType": "inlineComment"})),
941                ),
942            ],
943        );
944        let v = run_inline("paragraph", node);
945        assert!(combos(&v).is_empty(), "got: {v:?}");
946    }
947
948    #[test]
949    fn styling_marks_combine_freely() {
950        // strong + em + strike + underline all live in the formatted group.
951        let node = text_with_marks(
952            "hi",
953            vec![
954                mark("strong", None),
955                mark("em", None),
956                mark("strike", None),
957                mark("underline", None),
958            ],
959        );
960        let v = run_inline("paragraph", node);
961        assert!(combos(&v).is_empty(), "got: {v:?}");
962    }
963
964    #[test]
965    fn link_plus_text_color_is_allowed() {
966        // Both live in the formatted group per the pinned upstream schema, so
967        // this is NOT flagged (despite issue #1047's example listing it).
968        let node = text_with_marks(
969            "hi",
970            vec![
971                mark("link", Some(serde_json::json!({"href": "https://x.com"}))),
972                mark("textColor", Some(serde_json::json!({"color": "#0000ff"}))),
973            ],
974        );
975        let v = run_inline("paragraph", node);
976        assert!(combos(&v).is_empty(), "got: {v:?}");
977    }
978
979    #[test]
980    fn heading_bold_code_flags_both_disallowed_and_combination() {
981        // In a heading, `code` is disallowed outright (DisallowedMark) AND the
982        // bold+code pairing is a forbidden combination — so the violation list
983        // mixes variants. `combos` must surface only the combination, ignoring
984        // the DisallowedMark.
985        let node = text_with_marks("hi", vec![mark("strong", None), mark("code", None)]);
986        let v = run_inline("heading", node);
987        assert!(
988            v.iter()
989                .any(|x| matches!(x, AdfSchemaViolation::DisallowedMark { .. })),
990            "expected a DisallowedMark for code-on-heading too, got: {v:?}"
991        );
992        assert_eq!(combos(&v), vec![("strong", "code")], "got: {v:?}");
993    }
994
995    #[test]
996    fn only_first_conflicting_pair_is_reported() {
997        // code conflicts with both strong and em; only the first pair fires.
998        let node = text_with_marks(
999            "hi",
1000            vec![mark("code", None), mark("strong", None), mark("em", None)],
1001        );
1002        let v = run_inline("paragraph", node);
1003        assert_eq!(combos(&v), vec![("code", "strong")], "got: {v:?}");
1004    }
1005
1006    #[test]
1007    fn combination_check_records_parent_and_inline_index() {
1008        let node = text_with_marks("hi", vec![mark("code", None), mark("strong", None)]);
1009        let mut out = Vec::new();
1010        validate_marks("paragraph", &node, &[3_usize], &mut out);
1011        assert_eq!(out.len(), 1, "got: {out:?}");
1012        assert!(
1013            matches!(
1014                &out[0],
1015                AdfSchemaViolation::ForbiddenMarkCombination { parent_type, inline_index, path, .. }
1016                    if parent_type == "paragraph" && *inline_index == Some(3) && path.as_slice() == [3]
1017            ),
1018            "got: {out:?}"
1019        );
1020    }
1021
1022    #[test]
1023    fn marks_may_coexist_treats_unknown_marks_as_non_conflicting() {
1024        // A mark outside every group is not flagged by the combination check
1025        // (its legality is the allow-list's job).
1026        assert!(marks_may_coexist("code", "madeUpMark"));
1027        assert!(marks_may_coexist("madeUpMark", "strong"));
1028    }
1029
1030    // ── inline node under a parent without an inline-mark allow-list ──
1031
1032    #[test]
1033    fn inline_node_under_unknown_parent_skips_mark_check() {
1034        // Parent has no inline-mark allow-list, so the mark is neither
1035        // accepted-by-allow-list nor flagged. Validates that the
1036        // `if let Some(allowed) = allowed { ... }` guard short-circuits
1037        // cleanly when `allowed` is `None`, but the per-mark attr schema
1038        // still runs (link.href validation here).
1039        let node = text_with_marks(
1040            "x",
1041            vec![mark("link", Some(serde_json::json!({"href": "not a url"})))],
1042        );
1043        let v = run_inline("madeUpParent", node);
1044        // No DisallowedMark (no allow-list to violate).
1045        assert!(v
1046            .iter()
1047            .all(|v| !matches!(v, AdfSchemaViolation::DisallowedMark { .. })));
1048        // But the mark-attr validation still fires.
1049        assert!(v
1050            .iter()
1051            .any(|v| matches!(v, AdfSchemaViolation::InvalidMarkAttr { .. })));
1052    }
1053}