Skip to main content

omni_dev/atlassian/
adf.rs

1//! Atlassian Document Format (ADF) type definitions.
2//!
3//! Provides serde-compatible structs for the ADF JSON structure used by
4//! JIRA Cloud REST API v3 and Confluence Cloud REST API v2.
5
6use serde::{Deserialize, Serialize};
7
8/// The root ADF document node.
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
10pub struct AdfDocument {
11    /// ADF version (always 1).
12    pub version: u32,
13
14    /// Node type (always "doc").
15    #[serde(rename = "type")]
16    pub doc_type: String,
17
18    /// Top-level block content nodes.
19    pub content: Vec<AdfNode>,
20}
21
22impl AdfDocument {
23    /// Creates a new empty ADF document.
24    #[must_use]
25    pub fn new() -> Self {
26        Self {
27            version: 1,
28            doc_type: "doc".to_string(),
29            content: Vec::new(),
30        }
31    }
32}
33
34impl Default for AdfDocument {
35    fn default() -> Self {
36        Self::new()
37    }
38}
39
40/// A node in the ADF tree.
41///
42/// Represents both block nodes (paragraph, heading, codeBlock, etc.)
43/// and inline nodes (text, hardBreak, mention, etc.).
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
45pub struct AdfNode {
46    /// The node type identifier (e.g., "paragraph", "text", "heading").
47    #[serde(rename = "type")]
48    pub node_type: String,
49
50    /// Node-specific attributes (e.g., heading level, code language).
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub attrs: Option<serde_json::Value>,
53
54    /// Child content nodes.
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub content: Option<Vec<Self>>,
57
58    /// Text content (only present on text nodes).
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub text: Option<String>,
61
62    /// Inline marks applied to this node (bold, italic, link, etc.).
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub marks: Option<Vec<AdfMark>>,
65
66    /// Top-level local ID (used by expand, nestedExpand, and other node types).
67    #[serde(rename = "localId", skip_serializing_if = "Option::is_none")]
68    pub local_id: Option<String>,
69
70    /// Top-level parameters (used by expand nodes with macroMetadata, etc.).
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub parameters: Option<serde_json::Value>,
73}
74
75impl AdfNode {
76    /// Creates a text node with the given content.
77    #[must_use]
78    pub fn text(content: &str) -> Self {
79        Self {
80            node_type: "text".to_string(),
81            attrs: None,
82            content: None,
83            text: Some(content.to_string()),
84            marks: None,
85            local_id: None,
86            parameters: None,
87        }
88    }
89
90    /// Creates a text node with marks applied.
91    #[must_use]
92    pub fn text_with_marks(content: &str, marks: Vec<AdfMark>) -> Self {
93        Self {
94            node_type: "text".to_string(),
95            attrs: None,
96            content: None,
97            text: Some(content.to_string()),
98            marks: if marks.is_empty() { None } else { Some(marks) },
99            local_id: None,
100            parameters: None,
101        }
102    }
103
104    /// Creates a paragraph node with the given inline content.
105    #[must_use]
106    pub fn paragraph(content: Vec<Self>) -> Self {
107        Self {
108            node_type: "paragraph".to_string(),
109            attrs: None,
110            content: if content.is_empty() {
111                None
112            } else {
113                Some(content)
114            },
115            text: None,
116            marks: None,
117            local_id: None,
118            parameters: None,
119        }
120    }
121
122    /// Creates a heading node.
123    #[must_use]
124    pub fn heading(level: u8, content: Vec<Self>) -> Self {
125        Self {
126            node_type: "heading".to_string(),
127            attrs: Some(serde_json::json!({"level": level})),
128            content: if content.is_empty() {
129                None
130            } else {
131                Some(content)
132            },
133            text: None,
134            marks: None,
135            local_id: None,
136            parameters: None,
137        }
138    }
139
140    /// Creates a code block node.
141    #[must_use]
142    pub fn code_block(language: Option<&str>, text: &str) -> Self {
143        Self {
144            node_type: "codeBlock".to_string(),
145            attrs: language.map(|lang| serde_json::json!({"language": lang})),
146            content: Some(vec![Self::text(text)]),
147            text: None,
148            marks: None,
149            local_id: None,
150            parameters: None,
151        }
152    }
153
154    /// Creates a blockquote node.
155    #[must_use]
156    pub fn blockquote(content: Vec<Self>) -> Self {
157        Self {
158            node_type: "blockquote".to_string(),
159            attrs: None,
160            content: Some(content),
161            text: None,
162            marks: None,
163            local_id: None,
164            parameters: None,
165        }
166    }
167
168    /// Creates a horizontal rule node.
169    #[must_use]
170    pub fn rule() -> Self {
171        Self {
172            node_type: "rule".to_string(),
173            attrs: None,
174            content: None,
175            text: None,
176            marks: None,
177            local_id: None,
178            parameters: None,
179        }
180    }
181
182    /// Creates a bullet list node.
183    #[must_use]
184    pub fn bullet_list(items: Vec<Self>) -> Self {
185        Self {
186            node_type: "bulletList".to_string(),
187            attrs: None,
188            content: Some(items),
189            text: None,
190            marks: None,
191            local_id: None,
192            parameters: None,
193        }
194    }
195
196    /// Creates an ordered list node.
197    #[must_use]
198    pub fn ordered_list(items: Vec<Self>, start: Option<u32>) -> Self {
199        Self {
200            node_type: "orderedList".to_string(),
201            attrs: start.map(|s| serde_json::json!({"order": s})),
202            content: Some(items),
203            text: None,
204            marks: None,
205            local_id: None,
206            parameters: None,
207        }
208    }
209
210    /// Creates a list item node.
211    #[must_use]
212    pub fn list_item(content: Vec<Self>) -> Self {
213        Self {
214            node_type: "listItem".to_string(),
215            attrs: None,
216            content: Some(content),
217            text: None,
218            marks: None,
219            local_id: None,
220            parameters: None,
221        }
222    }
223
224    /// Creates a hard break node.
225    #[must_use]
226    pub fn hard_break() -> Self {
227        Self {
228            node_type: "hardBreak".to_string(),
229            attrs: None,
230            content: None,
231            text: None,
232            marks: None,
233            local_id: None,
234            parameters: None,
235        }
236    }
237
238    /// Creates a table node.
239    #[must_use]
240    pub fn table(rows: Vec<Self>) -> Self {
241        Self {
242            node_type: "table".to_string(),
243            attrs: None,
244            content: Some(rows),
245            text: None,
246            marks: None,
247            local_id: None,
248            parameters: None,
249        }
250    }
251
252    /// Creates a table node with attributes (layout, `isNumberColumnEnabled`).
253    #[must_use]
254    pub fn table_with_attrs(rows: Vec<Self>, attrs: serde_json::Value) -> Self {
255        Self {
256            node_type: "table".to_string(),
257            attrs: Some(attrs),
258            content: Some(rows),
259            text: None,
260            marks: None,
261            local_id: None,
262            parameters: None,
263        }
264    }
265
266    /// Creates a table row node.
267    #[must_use]
268    pub fn table_row(cells: Vec<Self>) -> Self {
269        Self {
270            node_type: "tableRow".to_string(),
271            attrs: None,
272            content: Some(cells),
273            text: None,
274            marks: None,
275            local_id: None,
276            parameters: None,
277        }
278    }
279
280    /// Creates a table header cell node.
281    #[must_use]
282    pub fn table_header(content: Vec<Self>) -> Self {
283        Self {
284            node_type: "tableHeader".to_string(),
285            attrs: None,
286            content: Some(content),
287            text: None,
288            marks: None,
289            local_id: None,
290            parameters: None,
291        }
292    }
293
294    /// Creates a table header cell node with attributes (colspan, rowspan, background, colwidth).
295    #[must_use]
296    pub fn table_header_with_attrs(content: Vec<Self>, attrs: serde_json::Value) -> Self {
297        Self {
298            node_type: "tableHeader".to_string(),
299            attrs: Some(attrs),
300            content: Some(content),
301            text: None,
302            marks: None,
303            local_id: None,
304            parameters: None,
305        }
306    }
307
308    /// Creates a table cell node.
309    #[must_use]
310    pub fn table_cell(content: Vec<Self>) -> Self {
311        Self {
312            node_type: "tableCell".to_string(),
313            attrs: None,
314            content: Some(content),
315            text: None,
316            marks: None,
317            local_id: None,
318            parameters: None,
319        }
320    }
321
322    /// Creates a table cell node with attributes (colspan, rowspan, background, colwidth).
323    #[must_use]
324    pub fn table_cell_with_attrs(content: Vec<Self>, attrs: serde_json::Value) -> Self {
325        Self {
326            node_type: "tableCell".to_string(),
327            attrs: Some(attrs),
328            content: Some(content),
329            text: None,
330            marks: None,
331            local_id: None,
332            parameters: None,
333        }
334    }
335
336    /// Creates a table header cell node with attributes and marks.
337    #[must_use]
338    pub fn table_header_with_attrs_and_marks(
339        content: Vec<Self>,
340        attrs: Option<serde_json::Value>,
341        marks: Vec<AdfMark>,
342    ) -> Self {
343        Self {
344            node_type: "tableHeader".to_string(),
345            attrs,
346            content: Some(content),
347            text: None,
348            marks: if marks.is_empty() { None } else { Some(marks) },
349            local_id: None,
350            parameters: None,
351        }
352    }
353
354    /// Creates a table cell node with attributes and marks.
355    #[must_use]
356    pub fn table_cell_with_attrs_and_marks(
357        content: Vec<Self>,
358        attrs: Option<serde_json::Value>,
359        marks: Vec<AdfMark>,
360    ) -> Self {
361        Self {
362            node_type: "tableCell".to_string(),
363            attrs,
364            content: Some(content),
365            text: None,
366            marks: if marks.is_empty() { None } else { Some(marks) },
367            local_id: None,
368            parameters: None,
369        }
370    }
371
372    /// Creates a caption node (used inside tables).
373    #[must_use]
374    pub fn caption(content: Vec<Self>) -> Self {
375        Self {
376            node_type: "caption".to_string(),
377            attrs: None,
378            content: Some(content),
379            text: None,
380            marks: None,
381            local_id: None,
382            parameters: None,
383        }
384    }
385
386    /// Creates an inline card node for a smart link (URL as both text and href).
387    #[must_use]
388    pub fn inline_card(url: &str) -> Self {
389        Self {
390            node_type: "inlineCard".to_string(),
391            attrs: Some(serde_json::json!({"url": url})),
392            content: None,
393            text: None,
394            marks: None,
395            local_id: None,
396            parameters: None,
397        }
398    }
399
400    /// Creates a `mediaInline` node with the given attributes.
401    #[must_use]
402    pub fn media_inline(attrs: serde_json::Value) -> Self {
403        Self {
404            node_type: "mediaInline".to_string(),
405            attrs: Some(attrs),
406            content: None,
407            text: None,
408            marks: None,
409            local_id: None,
410            parameters: None,
411        }
412    }
413
414    /// Creates a media single node wrapping an external image.
415    #[must_use]
416    pub fn media_single(url: &str, alt: Option<&str>) -> Self {
417        let mut media_attrs = serde_json::json!({
418            "type": "external",
419            "url": url,
420        });
421        if let Some(alt_text) = alt {
422            media_attrs["alt"] = serde_json::Value::String(alt_text.to_string());
423        }
424        Self {
425            node_type: "mediaSingle".to_string(),
426            attrs: Some(serde_json::json!({"layout": "center"})),
427            content: Some(vec![Self {
428                node_type: "media".to_string(),
429                attrs: Some(media_attrs),
430                content: None,
431                text: None,
432                marks: None,
433                local_id: None,
434                parameters: None,
435            }]),
436            text: None,
437            marks: None,
438            local_id: None,
439            parameters: None,
440        }
441    }
442
443    // ── Task lists ─────────────────────────────────────────────────
444
445    /// Creates a task list node.
446    #[must_use]
447    pub fn task_list(items: Vec<Self>) -> Self {
448        Self {
449            node_type: "taskList".to_string(),
450            attrs: Some(serde_json::json!({"localId": uuid_placeholder()})),
451            content: Some(items),
452            text: None,
453            marks: None,
454            local_id: None,
455            parameters: None,
456        }
457    }
458
459    /// Creates a task item node with state `"TODO"` or `"DONE"`.
460    #[must_use]
461    pub fn task_item(state: &str, content: Vec<Self>) -> Self {
462        Self {
463            node_type: "taskItem".to_string(),
464            attrs: Some(serde_json::json!({
465                "localId": uuid_placeholder(),
466                "state": state,
467            })),
468            content: if content.is_empty() {
469                None
470            } else {
471                Some(content)
472            },
473            text: None,
474            marks: None,
475            local_id: None,
476            parameters: None,
477        }
478    }
479
480    // ── Inline nodes ───────────────────────────────────────────────
481
482    /// Creates an emoji node.
483    #[must_use]
484    pub fn emoji(short_name: &str) -> Self {
485        Self {
486            node_type: "emoji".to_string(),
487            attrs: Some(serde_json::json!({"shortName": short_name})),
488            content: None,
489            text: None,
490            marks: None,
491            local_id: None,
492            parameters: None,
493        }
494    }
495
496    /// Creates a status badge node.
497    #[must_use]
498    pub fn status(text: &str, color: &str) -> Self {
499        Self {
500            node_type: "status".to_string(),
501            attrs: Some(serde_json::json!({
502                "text": text,
503                "color": color,
504                "localId": uuid_placeholder(),
505            })),
506            content: None,
507            text: None,
508            marks: None,
509            local_id: None,
510            parameters: None,
511        }
512    }
513
514    /// Creates a date node from an ISO 8601 date string.
515    #[must_use]
516    pub fn date(timestamp: &str) -> Self {
517        Self {
518            node_type: "date".to_string(),
519            attrs: Some(serde_json::json!({"timestamp": timestamp})),
520            content: None,
521            text: None,
522            marks: None,
523            local_id: None,
524            parameters: None,
525        }
526    }
527
528    /// Creates a placeholder node.
529    #[must_use]
530    pub fn placeholder(text: &str) -> Self {
531        Self {
532            node_type: "placeholder".to_string(),
533            attrs: Some(serde_json::json!({"text": text})),
534            content: None,
535            text: None,
536            marks: None,
537            local_id: None,
538            parameters: None,
539        }
540    }
541
542    /// Creates a mention node.
543    #[must_use]
544    pub fn mention(id: &str, display_text: &str) -> Self {
545        Self {
546            node_type: "mention".to_string(),
547            attrs: Some(serde_json::json!({
548                "id": id,
549                "text": display_text,
550            })),
551            content: None,
552            text: None,
553            marks: None,
554            local_id: None,
555            parameters: None,
556        }
557    }
558
559    // ── Block cards and embeds ─────────────────────────────────────
560
561    /// Creates a block card node (smart link displayed as a block).
562    #[must_use]
563    pub fn block_card(url: &str) -> Self {
564        Self {
565            node_type: "blockCard".to_string(),
566            attrs: Some(serde_json::json!({"url": url})),
567            content: None,
568            text: None,
569            marks: None,
570            local_id: None,
571            parameters: None,
572        }
573    }
574
575    /// Creates an embed card node.
576    #[must_use]
577    pub fn embed_card(
578        url: &str,
579        layout: Option<&str>,
580        original_height: Option<f64>,
581        width: Option<f64>,
582    ) -> Self {
583        let mut attrs = serde_json::json!({"url": url});
584        if let Some(l) = layout {
585            attrs["layout"] = serde_json::Value::String(l.to_string());
586        }
587        if let Some(h) = original_height {
588            attrs["originalHeight"] = serde_json::json!(h);
589        }
590        if let Some(w) = width {
591            attrs["width"] = serde_json::json!(w);
592        }
593        Self {
594            node_type: "embedCard".to_string(),
595            attrs: Some(attrs),
596            content: None,
597            text: None,
598            marks: None,
599            local_id: None,
600            parameters: None,
601        }
602    }
603
604    // ── Panels and expand ──────────────────────────────────────────
605
606    /// Creates a panel node.
607    #[must_use]
608    pub fn panel(panel_type: &str, content: Vec<Self>) -> Self {
609        Self {
610            node_type: "panel".to_string(),
611            attrs: Some(serde_json::json!({"panelType": panel_type})),
612            content: Some(content),
613            text: None,
614            marks: None,
615            local_id: None,
616            parameters: None,
617        }
618    }
619
620    /// Creates an expand (collapsible) node.
621    #[must_use]
622    pub fn expand(title: Option<&str>, content: Vec<Self>) -> Self {
623        let attrs = title.map(|t| serde_json::json!({"title": t}));
624        Self {
625            node_type: "expand".to_string(),
626            attrs,
627            content: Some(content),
628            text: None,
629            marks: None,
630            local_id: None,
631            parameters: None,
632        }
633    }
634
635    /// Creates a nested expand node.
636    #[must_use]
637    pub fn nested_expand(title: Option<&str>, content: Vec<Self>) -> Self {
638        let attrs = title.map(|t| serde_json::json!({"title": t}));
639        Self {
640            node_type: "nestedExpand".to_string(),
641            attrs,
642            content: Some(content),
643            text: None,
644            marks: None,
645            local_id: None,
646            parameters: None,
647        }
648    }
649
650    // ── Layout ─────────────────────────────────────────────────────
651
652    /// Creates a layout section node.
653    #[must_use]
654    pub fn layout_section(columns: Vec<Self>) -> Self {
655        Self {
656            node_type: "layoutSection".to_string(),
657            attrs: None,
658            content: Some(columns),
659            text: None,
660            marks: None,
661            local_id: None,
662            parameters: None,
663        }
664    }
665
666    /// Creates a layout column node.
667    #[must_use]
668    pub fn layout_column(width: f64, content: Vec<Self>) -> Self {
669        Self {
670            node_type: "layoutColumn".to_string(),
671            attrs: Some(serde_json::json!({"width": width})),
672            content: Some(content),
673            text: None,
674            marks: None,
675            local_id: None,
676            parameters: None,
677        }
678    }
679
680    // ── Decision lists ─────────────────────────────────────────────
681
682    /// Creates a decision list node.
683    #[must_use]
684    pub fn decision_list(items: Vec<Self>) -> Self {
685        Self {
686            node_type: "decisionList".to_string(),
687            attrs: Some(serde_json::json!({"localId": uuid_placeholder()})),
688            content: Some(items),
689            text: None,
690            marks: None,
691            local_id: None,
692            parameters: None,
693        }
694    }
695
696    /// Creates a decision item node.
697    #[must_use]
698    pub fn decision_item(state: &str, content: Vec<Self>) -> Self {
699        Self {
700            node_type: "decisionItem".to_string(),
701            attrs: Some(serde_json::json!({
702                "localId": uuid_placeholder(),
703                "state": state,
704            })),
705            content: Some(content),
706            text: None,
707            marks: None,
708            local_id: None,
709            parameters: None,
710        }
711    }
712
713    // ── Extensions ─────────────────────────────────────────────────
714
715    /// Creates a void (block) extension node.
716    #[must_use]
717    pub fn extension(
718        extension_type: &str,
719        extension_key: &str,
720        params: Option<serde_json::Value>,
721    ) -> Self {
722        let mut attrs = serde_json::json!({
723            "extensionType": extension_type,
724            "extensionKey": extension_key,
725        });
726        if let Some(p) = params {
727            attrs["parameters"] = p;
728        }
729        Self {
730            node_type: "extension".to_string(),
731            attrs: Some(attrs),
732            content: None,
733            text: None,
734            marks: None,
735            local_id: None,
736            parameters: None,
737        }
738    }
739
740    /// Creates a bodied extension node (extension with block content).
741    #[must_use]
742    pub fn bodied_extension(extension_type: &str, extension_key: &str, content: Vec<Self>) -> Self {
743        Self {
744            node_type: "bodiedExtension".to_string(),
745            attrs: Some(serde_json::json!({
746                "extensionType": extension_type,
747                "extensionKey": extension_key,
748            })),
749            content: Some(content),
750            text: None,
751            marks: None,
752            local_id: None,
753            parameters: None,
754        }
755    }
756
757    /// Creates an inline extension node.
758    #[must_use]
759    pub fn inline_extension(
760        extension_type: &str,
761        extension_key: &str,
762        fallback_text: Option<&str>,
763    ) -> Self {
764        Self {
765            node_type: "inlineExtension".to_string(),
766            attrs: Some(serde_json::json!({
767                "extensionType": extension_type,
768                "extensionKey": extension_key,
769            })),
770            content: None,
771            text: fallback_text.map(String::from),
772            marks: None,
773            local_id: None,
774            parameters: None,
775        }
776    }
777}
778
779/// Returns the default placeholder for nodes that require a `localId`.
780/// Empty string is used because Confluence itself emits `localId: ""`
781/// for auto-generated nodes; both `""` and the nil UUID
782/// `"00000000-0000-0000-0000-000000000000"` are treated as
783/// non-significant by the rendering layer.
784fn uuid_placeholder() -> String {
785    String::new()
786}
787
788/// An inline mark applied to a text node.
789#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
790pub struct AdfMark {
791    /// The mark type (e.g., "strong", "em", "code", "link", "strike").
792    #[serde(rename = "type")]
793    pub mark_type: String,
794
795    /// Mark-specific attributes (e.g., href for links).
796    #[serde(skip_serializing_if = "Option::is_none")]
797    pub attrs: Option<serde_json::Value>,
798}
799
800impl AdfMark {
801    /// Creates a strong (bold) mark.
802    #[must_use]
803    pub fn strong() -> Self {
804        Self {
805            mark_type: "strong".to_string(),
806            attrs: None,
807        }
808    }
809
810    /// Creates an emphasis (italic) mark.
811    #[must_use]
812    pub fn em() -> Self {
813        Self {
814            mark_type: "em".to_string(),
815            attrs: None,
816        }
817    }
818
819    /// Creates an inline code mark.
820    #[must_use]
821    pub fn code() -> Self {
822        Self {
823            mark_type: "code".to_string(),
824            attrs: None,
825        }
826    }
827
828    /// Creates a strikethrough mark.
829    #[must_use]
830    pub fn strike() -> Self {
831        Self {
832            mark_type: "strike".to_string(),
833            attrs: None,
834        }
835    }
836
837    /// Creates a link mark with the given URL.
838    #[must_use]
839    pub fn link(href: &str) -> Self {
840        Self {
841            mark_type: "link".to_string(),
842            attrs: Some(serde_json::json!({"href": href})),
843        }
844    }
845
846    /// Creates an underline mark.
847    #[must_use]
848    pub fn underline() -> Self {
849        Self {
850            mark_type: "underline".to_string(),
851            attrs: None,
852        }
853    }
854
855    /// Creates an annotation mark (inline comment highlight).
856    #[must_use]
857    pub fn annotation(id: &str, annotation_type: &str) -> Self {
858        Self {
859            mark_type: "annotation".to_string(),
860            attrs: Some(serde_json::json!({"id": id, "annotationType": annotation_type})),
861        }
862    }
863
864    /// Creates a text color mark.
865    #[must_use]
866    pub fn text_color(color: &str) -> Self {
867        Self {
868            mark_type: "textColor".to_string(),
869            attrs: Some(serde_json::json!({"color": color})),
870        }
871    }
872
873    /// Creates a background color mark.
874    #[must_use]
875    pub fn background_color(color: &str) -> Self {
876        Self {
877            mark_type: "backgroundColor".to_string(),
878            attrs: Some(serde_json::json!({"color": color})),
879        }
880    }
881
882    /// Creates a subscript or superscript mark.
883    #[must_use]
884    pub fn subsup(kind: &str) -> Self {
885        Self {
886            mark_type: "subsup".to_string(),
887            attrs: Some(serde_json::json!({"type": kind})),
888        }
889    }
890
891    /// Creates an alignment mark for block nodes.
892    #[must_use]
893    pub fn alignment(align: &str) -> Self {
894        Self {
895            mark_type: "alignment".to_string(),
896            attrs: Some(serde_json::json!({"align": align})),
897        }
898    }
899
900    /// Creates an indentation mark for block nodes.
901    #[must_use]
902    pub fn indentation(level: u32) -> Self {
903        Self {
904            mark_type: "indentation".to_string(),
905            attrs: Some(serde_json::json!({"level": level})),
906        }
907    }
908
909    /// Creates a breakout mark for block nodes.
910    #[must_use]
911    pub fn breakout(mode: &str, width: Option<u32>) -> Self {
912        let mut attrs = serde_json::json!({"mode": mode});
913        if let Some(w) = width {
914            attrs["width"] = serde_json::json!(w);
915        }
916        Self {
917            mark_type: "breakout".to_string(),
918            attrs: Some(attrs),
919        }
920    }
921
922    /// Creates a border mark for table cells/headers.
923    #[must_use]
924    pub fn border(color: &str, size: u32) -> Self {
925        Self {
926            mark_type: "border".to_string(),
927            attrs: Some(serde_json::json!({"color": color, "size": size})),
928        }
929    }
930}
931
932#[cfg(test)]
933#[allow(clippy::unwrap_used, clippy::expect_used)]
934mod tests {
935    use super::*;
936
937    #[test]
938    fn empty_document_serialization() {
939        let doc = AdfDocument::new();
940        let json = serde_json::to_string(&doc).unwrap();
941        assert!(json.contains(r#""version":1"#));
942        assert!(json.contains(r#""type":"doc""#));
943    }
944
945    #[test]
946    fn document_with_paragraph() {
947        let doc = AdfDocument {
948            version: 1,
949            doc_type: "doc".to_string(),
950            content: vec![AdfNode::paragraph(vec![AdfNode::text("Hello world")])],
951        };
952        let json = serde_json::to_value(&doc).unwrap();
953        let content = json["content"][0].clone();
954        assert_eq!(content["type"], "paragraph");
955        assert_eq!(content["content"][0]["text"], "Hello world");
956    }
957
958    #[test]
959    fn text_with_marks() {
960        let node = AdfNode::text_with_marks("bold text", vec![AdfMark::strong()]);
961        let json = serde_json::to_value(&node).unwrap();
962        assert_eq!(json["marks"][0]["type"], "strong");
963    }
964
965    #[test]
966    fn heading_with_level() {
967        let node = AdfNode::heading(2, vec![AdfNode::text("Title")]);
968        let json = serde_json::to_value(&node).unwrap();
969        assert_eq!(json["attrs"]["level"], 2);
970        assert_eq!(json["content"][0]["text"], "Title");
971    }
972
973    #[test]
974    fn code_block_with_language() {
975        let node = AdfNode::code_block(Some("rust"), "fn main() {}");
976        let json = serde_json::to_value(&node).unwrap();
977        assert_eq!(json["attrs"]["language"], "rust");
978        assert_eq!(json["content"][0]["text"], "fn main() {}");
979    }
980
981    #[test]
982    fn link_mark_attributes() {
983        let mark = AdfMark::link("https://example.com");
984        let json = serde_json::to_value(&mark).unwrap();
985        assert_eq!(json["attrs"]["href"], "https://example.com");
986    }
987
988    #[test]
989    fn real_jira_adf_deserialization() {
990        let adf_json = r#"{
991            "version": 1,
992            "type": "doc",
993            "content": [
994                {
995                    "type": "paragraph",
996                    "content": [
997                        {"type": "text", "text": "Hello "},
998                        {"type": "text", "text": "world", "marks": [{"type": "strong"}]}
999                    ]
1000                },
1001                {
1002                    "type": "heading",
1003                    "attrs": {"level": 2},
1004                    "content": [
1005                        {"type": "text", "text": "Section"}
1006                    ]
1007                }
1008            ]
1009        }"#;
1010
1011        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
1012        assert_eq!(doc.version, 1);
1013        assert_eq!(doc.content.len(), 2);
1014        assert_eq!(doc.content[0].node_type, "paragraph");
1015        assert_eq!(doc.content[1].node_type, "heading");
1016    }
1017
1018    #[test]
1019    fn round_trip_serialization() {
1020        let doc = AdfDocument {
1021            version: 1,
1022            doc_type: "doc".to_string(),
1023            content: vec![
1024                AdfNode::heading(1, vec![AdfNode::text("Title")]),
1025                AdfNode::paragraph(vec![
1026                    AdfNode::text("Normal "),
1027                    AdfNode::text_with_marks("bold", vec![AdfMark::strong()]),
1028                    AdfNode::text(" text"),
1029                ]),
1030                AdfNode::code_block(Some("rust"), "let x = 1;"),
1031                AdfNode::rule(),
1032            ],
1033        };
1034
1035        let json = serde_json::to_string(&doc).unwrap();
1036        let restored: AdfDocument = serde_json::from_str(&json).unwrap();
1037        assert_eq!(doc, restored);
1038    }
1039
1040    #[test]
1041    fn skip_none_fields_in_serialization() {
1042        let node = AdfNode::text("hello");
1043        let json = serde_json::to_value(&node).unwrap();
1044        assert!(json.get("attrs").is_none());
1045        assert!(json.get("content").is_none());
1046        assert!(json.get("marks").is_none());
1047    }
1048
1049    #[test]
1050    fn default_document() {
1051        let doc = AdfDocument::default();
1052        assert_eq!(doc.version, 1);
1053        assert_eq!(doc.doc_type, "doc");
1054        assert!(doc.content.is_empty());
1055    }
1056
1057    #[test]
1058    fn empty_paragraph_no_content() {
1059        let node = AdfNode::paragraph(vec![]);
1060        assert!(node.content.is_none());
1061    }
1062
1063    #[test]
1064    fn empty_heading_no_content() {
1065        let node = AdfNode::heading(1, vec![]);
1066        assert!(node.content.is_none());
1067    }
1068
1069    #[test]
1070    fn text_with_empty_marks_is_none() {
1071        let node = AdfNode::text_with_marks("test", vec![]);
1072        assert!(node.marks.is_none());
1073    }
1074
1075    #[test]
1076    fn code_block_no_language() {
1077        let node = AdfNode::code_block(None, "code");
1078        assert!(node.attrs.is_none());
1079        assert_eq!(
1080            node.content.as_ref().unwrap()[0].text.as_deref(),
1081            Some("code")
1082        );
1083    }
1084
1085    #[test]
1086    fn ordered_list_with_start() {
1087        let node = AdfNode::ordered_list(vec![], Some(5));
1088        let attrs = node.attrs.as_ref().unwrap();
1089        assert_eq!(attrs["order"], 5);
1090    }
1091
1092    #[test]
1093    fn ordered_list_no_start() {
1094        let node = AdfNode::ordered_list(vec![], None);
1095        assert!(node.attrs.is_none());
1096    }
1097
1098    #[test]
1099    fn media_single_with_alt() {
1100        let node = AdfNode::media_single("https://img.url", Some("Alt text"));
1101        let media = &node.content.as_ref().unwrap()[0];
1102        let attrs = media.attrs.as_ref().unwrap();
1103        assert_eq!(attrs["url"], "https://img.url");
1104        assert_eq!(attrs["alt"], "Alt text");
1105    }
1106
1107    #[test]
1108    fn media_single_no_alt() {
1109        let node = AdfNode::media_single("https://img.url", None);
1110        let media = &node.content.as_ref().unwrap()[0];
1111        let attrs = media.attrs.as_ref().unwrap();
1112        assert_eq!(attrs["url"], "https://img.url");
1113        assert!(attrs.get("alt").is_none());
1114    }
1115
1116    #[test]
1117    fn mark_constructors() {
1118        assert_eq!(AdfMark::em().mark_type, "em");
1119        assert_eq!(AdfMark::code().mark_type, "code");
1120        assert_eq!(AdfMark::strike().mark_type, "strike");
1121    }
1122
1123    #[test]
1124    fn table_structure() {
1125        let table = AdfNode::table(vec![AdfNode::table_row(vec![
1126            AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H")])]),
1127            AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C")])]),
1128        ])]);
1129        assert_eq!(table.node_type, "table");
1130        let row = &table.content.as_ref().unwrap()[0];
1131        assert_eq!(row.node_type, "tableRow");
1132        let cells = row.content.as_ref().unwrap();
1133        assert_eq!(cells[0].node_type, "tableHeader");
1134        assert_eq!(cells[1].node_type, "tableCell");
1135    }
1136
1137    #[test]
1138    fn blockquote_structure() {
1139        let bq = AdfNode::blockquote(vec![AdfNode::paragraph(vec![AdfNode::text("quoted")])]);
1140        assert_eq!(bq.node_type, "blockquote");
1141        assert_eq!(bq.content.as_ref().unwrap()[0].node_type, "paragraph");
1142    }
1143
1144    #[test]
1145    fn hard_break_structure() {
1146        let br = AdfNode::hard_break();
1147        assert_eq!(br.node_type, "hardBreak");
1148        assert!(br.content.is_none());
1149        assert!(br.text.is_none());
1150    }
1151
1152    #[test]
1153    fn rule_structure() {
1154        let rule = AdfNode::rule();
1155        assert_eq!(rule.node_type, "rule");
1156        assert!(rule.content.is_none());
1157    }
1158
1159    // ── Additional node constructors ────────────────────────────────
1160
1161    #[test]
1162    fn bullet_list_structure() {
1163        let item = AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("item")])]);
1164        let list = AdfNode::bullet_list(vec![item]);
1165        assert_eq!(list.node_type, "bulletList");
1166        assert_eq!(list.content.as_ref().unwrap().len(), 1);
1167    }
1168
1169    #[test]
1170    fn list_item_structure() {
1171        let item = AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("text")])]);
1172        assert_eq!(item.node_type, "listItem");
1173    }
1174
1175    #[test]
1176    fn inline_card_structure() {
1177        let card = AdfNode::inline_card("https://example.com");
1178        assert_eq!(card.node_type, "inlineCard");
1179        assert_eq!(card.attrs.as_ref().unwrap()["url"], "https://example.com");
1180    }
1181
1182    #[test]
1183    fn task_list_structure() {
1184        let item = AdfNode::task_item("TODO", vec![AdfNode::text("do this")]);
1185        let list = AdfNode::task_list(vec![item]);
1186        assert_eq!(list.node_type, "taskList");
1187        assert!(list.attrs.as_ref().unwrap()["localId"].is_string());
1188    }
1189
1190    #[test]
1191    fn task_item_states() {
1192        let todo = AdfNode::task_item("TODO", vec![]);
1193        assert_eq!(todo.attrs.as_ref().unwrap()["state"], "TODO");
1194
1195        let done = AdfNode::task_item("DONE", vec![]);
1196        assert_eq!(done.attrs.as_ref().unwrap()["state"], "DONE");
1197    }
1198
1199    #[test]
1200    fn emoji_node() {
1201        let node = AdfNode::emoji(":thumbsup:");
1202        assert_eq!(node.node_type, "emoji");
1203        assert_eq!(node.attrs.as_ref().unwrap()["shortName"], ":thumbsup:");
1204    }
1205
1206    #[test]
1207    fn status_node() {
1208        let node = AdfNode::status("In Progress", "blue");
1209        assert_eq!(node.node_type, "status");
1210        assert_eq!(node.attrs.as_ref().unwrap()["text"], "In Progress");
1211        assert_eq!(node.attrs.as_ref().unwrap()["color"], "blue");
1212    }
1213
1214    #[test]
1215    fn date_node() {
1216        let node = AdfNode::date("1680307200000");
1217        assert_eq!(node.node_type, "date");
1218        assert_eq!(node.attrs.as_ref().unwrap()["timestamp"], "1680307200000");
1219    }
1220
1221    #[test]
1222    fn mention_node() {
1223        let node = AdfNode::mention("user-123", "Alice");
1224        assert_eq!(node.node_type, "mention");
1225        assert_eq!(node.attrs.as_ref().unwrap()["id"], "user-123");
1226        assert_eq!(node.attrs.as_ref().unwrap()["text"], "Alice");
1227    }
1228
1229    #[test]
1230    fn block_card_structure() {
1231        let card = AdfNode::block_card("https://example.com/page");
1232        assert_eq!(card.node_type, "blockCard");
1233        assert_eq!(
1234            card.attrs.as_ref().unwrap()["url"],
1235            "https://example.com/page"
1236        );
1237    }
1238
1239    #[test]
1240    fn embed_card_with_all_options() {
1241        let card = AdfNode::embed_card(
1242            "https://example.com",
1243            Some("wide"),
1244            Some(732.0),
1245            Some(100.0),
1246        );
1247        let attrs = card.attrs.as_ref().unwrap();
1248        assert_eq!(attrs["url"], "https://example.com");
1249        assert_eq!(attrs["layout"], "wide");
1250        assert_eq!(attrs["originalHeight"], 732.0);
1251        assert_eq!(attrs["width"], 100.0);
1252    }
1253
1254    #[test]
1255    fn embed_card_minimal() {
1256        let card = AdfNode::embed_card("https://example.com", None, None, None);
1257        let attrs = card.attrs.as_ref().unwrap();
1258        assert_eq!(attrs["url"], "https://example.com");
1259        assert!(attrs.get("layout").is_none());
1260        assert!(attrs.get("originalHeight").is_none());
1261        assert!(attrs.get("width").is_none());
1262    }
1263
1264    #[test]
1265    fn panel_structure() {
1266        let panel = AdfNode::panel(
1267            "info",
1268            vec![AdfNode::paragraph(vec![AdfNode::text("note")])],
1269        );
1270        assert_eq!(panel.node_type, "panel");
1271        assert_eq!(panel.attrs.as_ref().unwrap()["panelType"], "info");
1272    }
1273
1274    #[test]
1275    fn expand_with_title() {
1276        let node = AdfNode::expand(Some("Details"), vec![AdfNode::paragraph(vec![])]);
1277        assert_eq!(node.node_type, "expand");
1278        assert_eq!(node.attrs.as_ref().unwrap()["title"], "Details");
1279    }
1280
1281    #[test]
1282    fn expand_without_title() {
1283        let node = AdfNode::expand(None, vec![AdfNode::paragraph(vec![])]);
1284        assert_eq!(node.node_type, "expand");
1285        assert!(node.attrs.is_none());
1286    }
1287
1288    #[test]
1289    fn nested_expand_structure() {
1290        let node = AdfNode::nested_expand(Some("Inner"), vec![]);
1291        assert_eq!(node.node_type, "nestedExpand");
1292        assert_eq!(node.attrs.as_ref().unwrap()["title"], "Inner");
1293    }
1294
1295    #[test]
1296    fn layout_section_and_column() {
1297        let col = AdfNode::layout_column(50.0, vec![AdfNode::paragraph(vec![])]);
1298        assert_eq!(col.node_type, "layoutColumn");
1299        assert_eq!(col.attrs.as_ref().unwrap()["width"], 50.0);
1300
1301        let section = AdfNode::layout_section(vec![col]);
1302        assert_eq!(section.node_type, "layoutSection");
1303    }
1304
1305    #[test]
1306    fn decision_list_and_item() {
1307        let item = AdfNode::decision_item("DECIDED", vec![AdfNode::text("yes")]);
1308        assert_eq!(item.attrs.as_ref().unwrap()["state"], "DECIDED");
1309
1310        let list = AdfNode::decision_list(vec![item]);
1311        assert_eq!(list.node_type, "decisionList");
1312    }
1313
1314    #[test]
1315    fn extension_with_params() {
1316        let node = AdfNode::extension(
1317            "com.atlassian.jira",
1318            "issue-list",
1319            Some(serde_json::json!({"jql": "project = PROJ"})),
1320        );
1321        assert_eq!(node.node_type, "extension");
1322        let attrs = node.attrs.as_ref().unwrap();
1323        assert_eq!(attrs["extensionType"], "com.atlassian.jira");
1324        assert_eq!(attrs["parameters"]["jql"], "project = PROJ");
1325    }
1326
1327    #[test]
1328    fn extension_without_params() {
1329        let node = AdfNode::extension("com.atlassian.jira", "issue-list", None);
1330        let attrs = node.attrs.as_ref().unwrap();
1331        assert!(attrs.get("parameters").is_none());
1332    }
1333
1334    #[test]
1335    fn bodied_extension_structure() {
1336        let node = AdfNode::bodied_extension(
1337            "com.atlassian.jira",
1338            "issue-list",
1339            vec![AdfNode::paragraph(vec![AdfNode::text("body")])],
1340        );
1341        assert_eq!(node.node_type, "bodiedExtension");
1342        assert!(node.content.is_some());
1343    }
1344
1345    #[test]
1346    fn inline_extension_structure() {
1347        let node = AdfNode::inline_extension("com.test", "inline-key", Some("fallback"));
1348        assert_eq!(node.node_type, "inlineExtension");
1349        assert_eq!(node.text.as_deref(), Some("fallback"));
1350    }
1351
1352    #[test]
1353    fn inline_extension_no_fallback() {
1354        let node = AdfNode::inline_extension("com.test", "inline-key", None);
1355        assert!(node.text.is_none());
1356    }
1357
1358    #[test]
1359    fn table_with_attrs_structure() {
1360        let row = AdfNode::table_row(vec![]);
1361        let table = AdfNode::table_with_attrs(
1362            vec![row],
1363            serde_json::json!({"isNumberColumnEnabled": true, "layout": "default"}),
1364        );
1365        assert_eq!(table.node_type, "table");
1366        assert_eq!(table.attrs.as_ref().unwrap()["isNumberColumnEnabled"], true);
1367    }
1368
1369    #[test]
1370    fn table_header_with_attrs_structure() {
1371        let header = AdfNode::table_header_with_attrs(
1372            vec![AdfNode::paragraph(vec![AdfNode::text("H")])],
1373            serde_json::json!({"colspan": 2, "background": "#deebff"}),
1374        );
1375        assert_eq!(header.node_type, "tableHeader");
1376        assert_eq!(header.attrs.as_ref().unwrap()["colspan"], 2);
1377    }
1378
1379    #[test]
1380    fn table_cell_with_attrs_structure() {
1381        let cell = AdfNode::table_cell_with_attrs(
1382            vec![AdfNode::paragraph(vec![AdfNode::text("C")])],
1383            serde_json::json!({"rowspan": 3}),
1384        );
1385        assert_eq!(cell.node_type, "tableCell");
1386        assert_eq!(cell.attrs.as_ref().unwrap()["rowspan"], 3);
1387    }
1388
1389    // ── Additional mark constructors ────────────────────────────────
1390
1391    #[test]
1392    fn underline_mark() {
1393        let mark = AdfMark::underline();
1394        assert_eq!(mark.mark_type, "underline");
1395        assert!(mark.attrs.is_none());
1396    }
1397
1398    #[test]
1399    fn text_color_mark() {
1400        let mark = AdfMark::text_color("#ff0000");
1401        assert_eq!(mark.mark_type, "textColor");
1402        assert_eq!(mark.attrs.as_ref().unwrap()["color"], "#ff0000");
1403    }
1404
1405    #[test]
1406    fn background_color_mark() {
1407        let mark = AdfMark::background_color("#00ff00");
1408        assert_eq!(mark.mark_type, "backgroundColor");
1409        assert_eq!(mark.attrs.as_ref().unwrap()["color"], "#00ff00");
1410    }
1411
1412    #[test]
1413    fn subsup_mark() {
1414        let mark = AdfMark::subsup("sub");
1415        assert_eq!(mark.mark_type, "subsup");
1416        assert_eq!(mark.attrs.as_ref().unwrap()["type"], "sub");
1417    }
1418
1419    #[test]
1420    fn alignment_mark() {
1421        let mark = AdfMark::alignment("center");
1422        assert_eq!(mark.mark_type, "alignment");
1423        assert_eq!(mark.attrs.as_ref().unwrap()["align"], "center");
1424    }
1425
1426    #[test]
1427    fn indentation_mark() {
1428        let mark = AdfMark::indentation(2);
1429        assert_eq!(mark.mark_type, "indentation");
1430        assert_eq!(mark.attrs.as_ref().unwrap()["level"], 2);
1431    }
1432
1433    #[test]
1434    fn breakout_mark() {
1435        let mark = AdfMark::breakout("wide", None);
1436        assert_eq!(mark.mark_type, "breakout");
1437        assert_eq!(mark.attrs.as_ref().unwrap()["mode"], "wide");
1438        assert!(mark.attrs.as_ref().unwrap().get("width").is_none());
1439    }
1440
1441    #[test]
1442    fn breakout_mark_with_width() {
1443        let mark = AdfMark::breakout("wide", Some(1200));
1444        assert_eq!(mark.mark_type, "breakout");
1445        assert_eq!(mark.attrs.as_ref().unwrap()["mode"], "wide");
1446        assert_eq!(mark.attrs.as_ref().unwrap()["width"], 1200);
1447    }
1448
1449    #[test]
1450    fn border_mark() {
1451        let mark = AdfMark::border("#ff000033", 2);
1452        assert_eq!(mark.mark_type, "border");
1453        let attrs = mark.attrs.as_ref().unwrap();
1454        assert_eq!(attrs["color"], "#ff000033");
1455        assert_eq!(attrs["size"], 2);
1456    }
1457
1458    #[test]
1459    fn table_cell_with_attrs_and_marks_builder() {
1460        let cell = AdfNode::table_cell_with_attrs_and_marks(
1461            vec![AdfNode::paragraph(vec![])],
1462            Some(serde_json::json!({"background": "#e6fcff"})),
1463            vec![AdfMark::border("#ff0000", 1)],
1464        );
1465        assert_eq!(cell.node_type, "tableCell");
1466        assert!(cell.marks.is_some());
1467        assert_eq!(cell.marks.as_ref().unwrap()[0].mark_type, "border");
1468    }
1469
1470    #[test]
1471    fn table_header_with_attrs_and_marks_builder() {
1472        let cell = AdfNode::table_header_with_attrs_and_marks(
1473            vec![AdfNode::paragraph(vec![])],
1474            None,
1475            vec![AdfMark::border("#0000ff", 3)],
1476        );
1477        assert_eq!(cell.node_type, "tableHeader");
1478        assert!(cell.marks.is_some());
1479        assert_eq!(cell.marks.as_ref().unwrap()[0].mark_type, "border");
1480    }
1481
1482    #[test]
1483    fn table_cell_with_empty_marks_has_none() {
1484        let cell = AdfNode::table_cell_with_attrs_and_marks(
1485            vec![AdfNode::paragraph(vec![])],
1486            None,
1487            vec![],
1488        );
1489        assert!(cell.marks.is_none(), "empty marks vec should become None");
1490    }
1491
1492    #[test]
1493    fn border_mark_serde_roundtrip() {
1494        let mark = AdfMark::border("#ff000033", 2);
1495        let json = serde_json::to_string(&mark).unwrap();
1496        let deserialized: AdfMark = serde_json::from_str(&json).unwrap();
1497        assert_eq!(deserialized.mark_type, "border");
1498        assert_eq!(deserialized.attrs.as_ref().unwrap()["color"], "#ff000033");
1499        assert_eq!(deserialized.attrs.as_ref().unwrap()["size"], 2);
1500    }
1501}