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
67impl AdfNode {
68    /// Creates a text node with the given content.
69    #[must_use]
70    pub fn text(content: &str) -> Self {
71        Self {
72            node_type: "text".to_string(),
73            attrs: None,
74            content: None,
75            text: Some(content.to_string()),
76            marks: None,
77        }
78    }
79
80    /// Creates a text node with marks applied.
81    #[must_use]
82    pub fn text_with_marks(content: &str, marks: Vec<AdfMark>) -> Self {
83        Self {
84            node_type: "text".to_string(),
85            attrs: None,
86            content: None,
87            text: Some(content.to_string()),
88            marks: if marks.is_empty() { None } else { Some(marks) },
89        }
90    }
91
92    /// Creates a paragraph node with the given inline content.
93    #[must_use]
94    pub fn paragraph(content: Vec<Self>) -> Self {
95        Self {
96            node_type: "paragraph".to_string(),
97            attrs: None,
98            content: if content.is_empty() {
99                None
100            } else {
101                Some(content)
102            },
103            text: None,
104            marks: None,
105        }
106    }
107
108    /// Creates a heading node.
109    #[must_use]
110    pub fn heading(level: u8, content: Vec<Self>) -> Self {
111        Self {
112            node_type: "heading".to_string(),
113            attrs: Some(serde_json::json!({"level": level})),
114            content: if content.is_empty() {
115                None
116            } else {
117                Some(content)
118            },
119            text: None,
120            marks: None,
121        }
122    }
123
124    /// Creates a code block node.
125    #[must_use]
126    pub fn code_block(language: Option<&str>, text: &str) -> Self {
127        Self {
128            node_type: "codeBlock".to_string(),
129            attrs: language.map(|lang| serde_json::json!({"language": lang})),
130            content: Some(vec![Self::text(text)]),
131            text: None,
132            marks: None,
133        }
134    }
135
136    /// Creates a blockquote node.
137    #[must_use]
138    pub fn blockquote(content: Vec<Self>) -> Self {
139        Self {
140            node_type: "blockquote".to_string(),
141            attrs: None,
142            content: Some(content),
143            text: None,
144            marks: None,
145        }
146    }
147
148    /// Creates a horizontal rule node.
149    #[must_use]
150    pub fn rule() -> Self {
151        Self {
152            node_type: "rule".to_string(),
153            attrs: None,
154            content: None,
155            text: None,
156            marks: None,
157        }
158    }
159
160    /// Creates a bullet list node.
161    #[must_use]
162    pub fn bullet_list(items: Vec<Self>) -> Self {
163        Self {
164            node_type: "bulletList".to_string(),
165            attrs: None,
166            content: Some(items),
167            text: None,
168            marks: None,
169        }
170    }
171
172    /// Creates an ordered list node.
173    #[must_use]
174    pub fn ordered_list(items: Vec<Self>, start: Option<u32>) -> Self {
175        Self {
176            node_type: "orderedList".to_string(),
177            attrs: start.map(|s| serde_json::json!({"order": s})),
178            content: Some(items),
179            text: None,
180            marks: None,
181        }
182    }
183
184    /// Creates a list item node.
185    #[must_use]
186    pub fn list_item(content: Vec<Self>) -> Self {
187        Self {
188            node_type: "listItem".to_string(),
189            attrs: None,
190            content: Some(content),
191            text: None,
192            marks: None,
193        }
194    }
195
196    /// Creates a hard break node.
197    #[must_use]
198    pub fn hard_break() -> Self {
199        Self {
200            node_type: "hardBreak".to_string(),
201            attrs: None,
202            content: None,
203            text: None,
204            marks: None,
205        }
206    }
207
208    /// Creates a table node.
209    #[must_use]
210    pub fn table(rows: Vec<Self>) -> Self {
211        Self {
212            node_type: "table".to_string(),
213            attrs: None,
214            content: Some(rows),
215            text: None,
216            marks: None,
217        }
218    }
219
220    /// Creates a table node with attributes (layout, `isNumberColumnEnabled`).
221    #[must_use]
222    pub fn table_with_attrs(rows: Vec<Self>, attrs: serde_json::Value) -> Self {
223        Self {
224            node_type: "table".to_string(),
225            attrs: Some(attrs),
226            content: Some(rows),
227            text: None,
228            marks: None,
229        }
230    }
231
232    /// Creates a table row node.
233    #[must_use]
234    pub fn table_row(cells: Vec<Self>) -> Self {
235        Self {
236            node_type: "tableRow".to_string(),
237            attrs: None,
238            content: Some(cells),
239            text: None,
240            marks: None,
241        }
242    }
243
244    /// Creates a table header cell node.
245    #[must_use]
246    pub fn table_header(content: Vec<Self>) -> Self {
247        Self {
248            node_type: "tableHeader".to_string(),
249            attrs: None,
250            content: Some(content),
251            text: None,
252            marks: None,
253        }
254    }
255
256    /// Creates a table header cell node with attributes (colspan, rowspan, background, colwidth).
257    #[must_use]
258    pub fn table_header_with_attrs(content: Vec<Self>, attrs: serde_json::Value) -> Self {
259        Self {
260            node_type: "tableHeader".to_string(),
261            attrs: Some(attrs),
262            content: Some(content),
263            text: None,
264            marks: None,
265        }
266    }
267
268    /// Creates a table cell node.
269    #[must_use]
270    pub fn table_cell(content: Vec<Self>) -> Self {
271        Self {
272            node_type: "tableCell".to_string(),
273            attrs: None,
274            content: Some(content),
275            text: None,
276            marks: None,
277        }
278    }
279
280    /// Creates a table cell node with attributes (colspan, rowspan, background, colwidth).
281    #[must_use]
282    pub fn table_cell_with_attrs(content: Vec<Self>, attrs: serde_json::Value) -> Self {
283        Self {
284            node_type: "tableCell".to_string(),
285            attrs: Some(attrs),
286            content: Some(content),
287            text: None,
288            marks: None,
289        }
290    }
291
292    /// Creates an inline card node for a smart link (URL as both text and href).
293    #[must_use]
294    pub fn inline_card(url: &str) -> Self {
295        Self {
296            node_type: "inlineCard".to_string(),
297            attrs: Some(serde_json::json!({"url": url})),
298            content: None,
299            text: None,
300            marks: None,
301        }
302    }
303
304    /// Creates a media single node wrapping an external image.
305    #[must_use]
306    pub fn media_single(url: &str, alt: Option<&str>) -> Self {
307        let mut media_attrs = serde_json::json!({
308            "type": "external",
309            "url": url,
310        });
311        if let Some(alt_text) = alt {
312            media_attrs["alt"] = serde_json::Value::String(alt_text.to_string());
313        }
314        Self {
315            node_type: "mediaSingle".to_string(),
316            attrs: Some(serde_json::json!({"layout": "center"})),
317            content: Some(vec![Self {
318                node_type: "media".to_string(),
319                attrs: Some(media_attrs),
320                content: None,
321                text: None,
322                marks: None,
323            }]),
324            text: None,
325            marks: None,
326        }
327    }
328
329    // ── Task lists ─────────────────────────────────────────────────
330
331    /// Creates a task list node.
332    #[must_use]
333    pub fn task_list(items: Vec<Self>) -> Self {
334        Self {
335            node_type: "taskList".to_string(),
336            attrs: Some(serde_json::json!({"localId": uuid_placeholder()})),
337            content: Some(items),
338            text: None,
339            marks: None,
340        }
341    }
342
343    /// Creates a task item node with state `"TODO"` or `"DONE"`.
344    #[must_use]
345    pub fn task_item(state: &str, content: Vec<Self>) -> Self {
346        Self {
347            node_type: "taskItem".to_string(),
348            attrs: Some(serde_json::json!({
349                "localId": uuid_placeholder(),
350                "state": state,
351            })),
352            content: Some(content),
353            text: None,
354            marks: None,
355        }
356    }
357
358    // ── Inline nodes ───────────────────────────────────────────────
359
360    /// Creates an emoji node.
361    #[must_use]
362    pub fn emoji(short_name: &str) -> Self {
363        Self {
364            node_type: "emoji".to_string(),
365            attrs: Some(serde_json::json!({"shortName": short_name})),
366            content: None,
367            text: None,
368            marks: None,
369        }
370    }
371
372    /// Creates a status badge node.
373    #[must_use]
374    pub fn status(text: &str, color: &str) -> Self {
375        Self {
376            node_type: "status".to_string(),
377            attrs: Some(serde_json::json!({
378                "text": text,
379                "color": color,
380                "localId": uuid_placeholder(),
381            })),
382            content: None,
383            text: None,
384            marks: None,
385        }
386    }
387
388    /// Creates a date node from an ISO 8601 date string.
389    #[must_use]
390    pub fn date(timestamp: &str) -> Self {
391        Self {
392            node_type: "date".to_string(),
393            attrs: Some(serde_json::json!({"timestamp": timestamp})),
394            content: None,
395            text: None,
396            marks: None,
397        }
398    }
399
400    /// Creates a mention node.
401    #[must_use]
402    pub fn mention(id: &str, display_text: &str) -> Self {
403        Self {
404            node_type: "mention".to_string(),
405            attrs: Some(serde_json::json!({
406                "id": id,
407                "text": display_text,
408            })),
409            content: None,
410            text: None,
411            marks: None,
412        }
413    }
414
415    // ── Block cards and embeds ─────────────────────────────────────
416
417    /// Creates a block card node (smart link displayed as a block).
418    #[must_use]
419    pub fn block_card(url: &str) -> Self {
420        Self {
421            node_type: "blockCard".to_string(),
422            attrs: Some(serde_json::json!({"url": url})),
423            content: None,
424            text: None,
425            marks: None,
426        }
427    }
428
429    /// Creates an embed card node.
430    #[must_use]
431    pub fn embed_card(url: &str, layout: Option<&str>, width: Option<u32>) -> Self {
432        let mut attrs = serde_json::json!({"url": url});
433        if let Some(l) = layout {
434            attrs["layout"] = serde_json::Value::String(l.to_string());
435        }
436        if let Some(w) = width {
437            attrs["width"] = serde_json::json!(w);
438        }
439        Self {
440            node_type: "embedCard".to_string(),
441            attrs: Some(attrs),
442            content: None,
443            text: None,
444            marks: None,
445        }
446    }
447
448    // ── Panels and expand ──────────────────────────────────────────
449
450    /// Creates a panel node.
451    #[must_use]
452    pub fn panel(panel_type: &str, content: Vec<Self>) -> Self {
453        Self {
454            node_type: "panel".to_string(),
455            attrs: Some(serde_json::json!({"panelType": panel_type})),
456            content: Some(content),
457            text: None,
458            marks: None,
459        }
460    }
461
462    /// Creates an expand (collapsible) node.
463    #[must_use]
464    pub fn expand(title: Option<&str>, content: Vec<Self>) -> Self {
465        let attrs = title.map(|t| serde_json::json!({"title": t}));
466        Self {
467            node_type: "expand".to_string(),
468            attrs,
469            content: Some(content),
470            text: None,
471            marks: None,
472        }
473    }
474
475    /// Creates a nested expand node.
476    #[must_use]
477    pub fn nested_expand(title: Option<&str>, content: Vec<Self>) -> Self {
478        let attrs = title.map(|t| serde_json::json!({"title": t}));
479        Self {
480            node_type: "nestedExpand".to_string(),
481            attrs,
482            content: Some(content),
483            text: None,
484            marks: None,
485        }
486    }
487
488    // ── Layout ─────────────────────────────────────────────────────
489
490    /// Creates a layout section node.
491    #[must_use]
492    pub fn layout_section(columns: Vec<Self>) -> Self {
493        Self {
494            node_type: "layoutSection".to_string(),
495            attrs: None,
496            content: Some(columns),
497            text: None,
498            marks: None,
499        }
500    }
501
502    /// Creates a layout column node.
503    #[must_use]
504    pub fn layout_column(width: f64, content: Vec<Self>) -> Self {
505        Self {
506            node_type: "layoutColumn".to_string(),
507            attrs: Some(serde_json::json!({"width": width})),
508            content: Some(content),
509            text: None,
510            marks: None,
511        }
512    }
513
514    // ── Decision lists ─────────────────────────────────────────────
515
516    /// Creates a decision list node.
517    #[must_use]
518    pub fn decision_list(items: Vec<Self>) -> Self {
519        Self {
520            node_type: "decisionList".to_string(),
521            attrs: Some(serde_json::json!({"localId": uuid_placeholder()})),
522            content: Some(items),
523            text: None,
524            marks: None,
525        }
526    }
527
528    /// Creates a decision item node.
529    #[must_use]
530    pub fn decision_item(state: &str, content: Vec<Self>) -> Self {
531        Self {
532            node_type: "decisionItem".to_string(),
533            attrs: Some(serde_json::json!({
534                "localId": uuid_placeholder(),
535                "state": state,
536            })),
537            content: Some(content),
538            text: None,
539            marks: None,
540        }
541    }
542
543    // ── Extensions ─────────────────────────────────────────────────
544
545    /// Creates a void (block) extension node.
546    #[must_use]
547    pub fn extension(
548        extension_type: &str,
549        extension_key: &str,
550        params: Option<serde_json::Value>,
551    ) -> Self {
552        let mut attrs = serde_json::json!({
553            "extensionType": extension_type,
554            "extensionKey": extension_key,
555        });
556        if let Some(p) = params {
557            attrs["parameters"] = p;
558        }
559        Self {
560            node_type: "extension".to_string(),
561            attrs: Some(attrs),
562            content: None,
563            text: None,
564            marks: None,
565        }
566    }
567
568    /// Creates a bodied extension node (extension with block content).
569    #[must_use]
570    pub fn bodied_extension(extension_type: &str, extension_key: &str, content: Vec<Self>) -> Self {
571        Self {
572            node_type: "bodiedExtension".to_string(),
573            attrs: Some(serde_json::json!({
574                "extensionType": extension_type,
575                "extensionKey": extension_key,
576            })),
577            content: Some(content),
578            text: None,
579            marks: None,
580        }
581    }
582
583    /// Creates an inline extension node.
584    #[must_use]
585    pub fn inline_extension(
586        extension_type: &str,
587        extension_key: &str,
588        fallback_text: Option<&str>,
589    ) -> Self {
590        Self {
591            node_type: "inlineExtension".to_string(),
592            attrs: Some(serde_json::json!({
593                "extensionType": extension_type,
594                "extensionKey": extension_key,
595            })),
596            content: None,
597            text: fallback_text.map(String::from),
598            marks: None,
599        }
600    }
601}
602
603/// Generates a placeholder UUID for nodes that require a `localId`.
604/// Real UUIDs should be generated at conversion time; this provides a
605/// deterministic fallback for testing.
606fn uuid_placeholder() -> String {
607    "00000000-0000-0000-0000-000000000000".to_string()
608}
609
610/// An inline mark applied to a text node.
611#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
612pub struct AdfMark {
613    /// The mark type (e.g., "strong", "em", "code", "link", "strike").
614    #[serde(rename = "type")]
615    pub mark_type: String,
616
617    /// Mark-specific attributes (e.g., href for links).
618    #[serde(skip_serializing_if = "Option::is_none")]
619    pub attrs: Option<serde_json::Value>,
620}
621
622impl AdfMark {
623    /// Creates a strong (bold) mark.
624    #[must_use]
625    pub fn strong() -> Self {
626        Self {
627            mark_type: "strong".to_string(),
628            attrs: None,
629        }
630    }
631
632    /// Creates an emphasis (italic) mark.
633    #[must_use]
634    pub fn em() -> Self {
635        Self {
636            mark_type: "em".to_string(),
637            attrs: None,
638        }
639    }
640
641    /// Creates an inline code mark.
642    #[must_use]
643    pub fn code() -> Self {
644        Self {
645            mark_type: "code".to_string(),
646            attrs: None,
647        }
648    }
649
650    /// Creates a strikethrough mark.
651    #[must_use]
652    pub fn strike() -> Self {
653        Self {
654            mark_type: "strike".to_string(),
655            attrs: None,
656        }
657    }
658
659    /// Creates a link mark with the given URL.
660    #[must_use]
661    pub fn link(href: &str) -> Self {
662        Self {
663            mark_type: "link".to_string(),
664            attrs: Some(serde_json::json!({"href": href})),
665        }
666    }
667
668    /// Creates an underline mark.
669    #[must_use]
670    pub fn underline() -> Self {
671        Self {
672            mark_type: "underline".to_string(),
673            attrs: None,
674        }
675    }
676
677    /// Creates a text color mark.
678    #[must_use]
679    pub fn text_color(color: &str) -> Self {
680        Self {
681            mark_type: "textColor".to_string(),
682            attrs: Some(serde_json::json!({"color": color})),
683        }
684    }
685
686    /// Creates a background color mark.
687    #[must_use]
688    pub fn background_color(color: &str) -> Self {
689        Self {
690            mark_type: "backgroundColor".to_string(),
691            attrs: Some(serde_json::json!({"color": color})),
692        }
693    }
694
695    /// Creates a subscript or superscript mark.
696    #[must_use]
697    pub fn subsup(kind: &str) -> Self {
698        Self {
699            mark_type: "subsup".to_string(),
700            attrs: Some(serde_json::json!({"type": kind})),
701        }
702    }
703
704    /// Creates an alignment mark for block nodes.
705    #[must_use]
706    pub fn alignment(align: &str) -> Self {
707        Self {
708            mark_type: "alignment".to_string(),
709            attrs: Some(serde_json::json!({"align": align})),
710        }
711    }
712
713    /// Creates an indentation mark for block nodes.
714    #[must_use]
715    pub fn indentation(level: u32) -> Self {
716        Self {
717            mark_type: "indentation".to_string(),
718            attrs: Some(serde_json::json!({"level": level})),
719        }
720    }
721
722    /// Creates a breakout mark for block nodes.
723    #[must_use]
724    pub fn breakout(mode: &str) -> Self {
725        Self {
726            mark_type: "breakout".to_string(),
727            attrs: Some(serde_json::json!({"mode": mode})),
728        }
729    }
730}
731
732#[cfg(test)]
733#[allow(clippy::unwrap_used, clippy::expect_used)]
734mod tests {
735    use super::*;
736
737    #[test]
738    fn empty_document_serialization() {
739        let doc = AdfDocument::new();
740        let json = serde_json::to_string(&doc).unwrap();
741        assert!(json.contains(r#""version":1"#));
742        assert!(json.contains(r#""type":"doc""#));
743    }
744
745    #[test]
746    fn document_with_paragraph() {
747        let doc = AdfDocument {
748            version: 1,
749            doc_type: "doc".to_string(),
750            content: vec![AdfNode::paragraph(vec![AdfNode::text("Hello world")])],
751        };
752        let json = serde_json::to_value(&doc).unwrap();
753        let content = json["content"][0].clone();
754        assert_eq!(content["type"], "paragraph");
755        assert_eq!(content["content"][0]["text"], "Hello world");
756    }
757
758    #[test]
759    fn text_with_marks() {
760        let node = AdfNode::text_with_marks("bold text", vec![AdfMark::strong()]);
761        let json = serde_json::to_value(&node).unwrap();
762        assert_eq!(json["marks"][0]["type"], "strong");
763    }
764
765    #[test]
766    fn heading_with_level() {
767        let node = AdfNode::heading(2, vec![AdfNode::text("Title")]);
768        let json = serde_json::to_value(&node).unwrap();
769        assert_eq!(json["attrs"]["level"], 2);
770        assert_eq!(json["content"][0]["text"], "Title");
771    }
772
773    #[test]
774    fn code_block_with_language() {
775        let node = AdfNode::code_block(Some("rust"), "fn main() {}");
776        let json = serde_json::to_value(&node).unwrap();
777        assert_eq!(json["attrs"]["language"], "rust");
778        assert_eq!(json["content"][0]["text"], "fn main() {}");
779    }
780
781    #[test]
782    fn link_mark_attributes() {
783        let mark = AdfMark::link("https://example.com");
784        let json = serde_json::to_value(&mark).unwrap();
785        assert_eq!(json["attrs"]["href"], "https://example.com");
786    }
787
788    #[test]
789    fn real_jira_adf_deserialization() {
790        let adf_json = r#"{
791            "version": 1,
792            "type": "doc",
793            "content": [
794                {
795                    "type": "paragraph",
796                    "content": [
797                        {"type": "text", "text": "Hello "},
798                        {"type": "text", "text": "world", "marks": [{"type": "strong"}]}
799                    ]
800                },
801                {
802                    "type": "heading",
803                    "attrs": {"level": 2},
804                    "content": [
805                        {"type": "text", "text": "Section"}
806                    ]
807                }
808            ]
809        }"#;
810
811        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
812        assert_eq!(doc.version, 1);
813        assert_eq!(doc.content.len(), 2);
814        assert_eq!(doc.content[0].node_type, "paragraph");
815        assert_eq!(doc.content[1].node_type, "heading");
816    }
817
818    #[test]
819    fn round_trip_serialization() {
820        let doc = AdfDocument {
821            version: 1,
822            doc_type: "doc".to_string(),
823            content: vec![
824                AdfNode::heading(1, vec![AdfNode::text("Title")]),
825                AdfNode::paragraph(vec![
826                    AdfNode::text("Normal "),
827                    AdfNode::text_with_marks("bold", vec![AdfMark::strong()]),
828                    AdfNode::text(" text"),
829                ]),
830                AdfNode::code_block(Some("rust"), "let x = 1;"),
831                AdfNode::rule(),
832            ],
833        };
834
835        let json = serde_json::to_string(&doc).unwrap();
836        let restored: AdfDocument = serde_json::from_str(&json).unwrap();
837        assert_eq!(doc, restored);
838    }
839
840    #[test]
841    fn skip_none_fields_in_serialization() {
842        let node = AdfNode::text("hello");
843        let json = serde_json::to_value(&node).unwrap();
844        assert!(json.get("attrs").is_none());
845        assert!(json.get("content").is_none());
846        assert!(json.get("marks").is_none());
847    }
848
849    #[test]
850    fn default_document() {
851        let doc = AdfDocument::default();
852        assert_eq!(doc.version, 1);
853        assert_eq!(doc.doc_type, "doc");
854        assert!(doc.content.is_empty());
855    }
856
857    #[test]
858    fn empty_paragraph_no_content() {
859        let node = AdfNode::paragraph(vec![]);
860        assert!(node.content.is_none());
861    }
862
863    #[test]
864    fn empty_heading_no_content() {
865        let node = AdfNode::heading(1, vec![]);
866        assert!(node.content.is_none());
867    }
868
869    #[test]
870    fn text_with_empty_marks_is_none() {
871        let node = AdfNode::text_with_marks("test", vec![]);
872        assert!(node.marks.is_none());
873    }
874
875    #[test]
876    fn code_block_no_language() {
877        let node = AdfNode::code_block(None, "code");
878        assert!(node.attrs.is_none());
879        assert_eq!(
880            node.content.as_ref().unwrap()[0].text.as_deref(),
881            Some("code")
882        );
883    }
884
885    #[test]
886    fn ordered_list_with_start() {
887        let node = AdfNode::ordered_list(vec![], Some(5));
888        let attrs = node.attrs.as_ref().unwrap();
889        assert_eq!(attrs["order"], 5);
890    }
891
892    #[test]
893    fn ordered_list_no_start() {
894        let node = AdfNode::ordered_list(vec![], None);
895        assert!(node.attrs.is_none());
896    }
897
898    #[test]
899    fn media_single_with_alt() {
900        let node = AdfNode::media_single("https://img.url", Some("Alt text"));
901        let media = &node.content.as_ref().unwrap()[0];
902        let attrs = media.attrs.as_ref().unwrap();
903        assert_eq!(attrs["url"], "https://img.url");
904        assert_eq!(attrs["alt"], "Alt text");
905    }
906
907    #[test]
908    fn media_single_no_alt() {
909        let node = AdfNode::media_single("https://img.url", None);
910        let media = &node.content.as_ref().unwrap()[0];
911        let attrs = media.attrs.as_ref().unwrap();
912        assert_eq!(attrs["url"], "https://img.url");
913        assert!(attrs.get("alt").is_none());
914    }
915
916    #[test]
917    fn mark_constructors() {
918        assert_eq!(AdfMark::em().mark_type, "em");
919        assert_eq!(AdfMark::code().mark_type, "code");
920        assert_eq!(AdfMark::strike().mark_type, "strike");
921    }
922
923    #[test]
924    fn table_structure() {
925        let table = AdfNode::table(vec![AdfNode::table_row(vec![
926            AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H")])]),
927            AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C")])]),
928        ])]);
929        assert_eq!(table.node_type, "table");
930        let row = &table.content.as_ref().unwrap()[0];
931        assert_eq!(row.node_type, "tableRow");
932        let cells = row.content.as_ref().unwrap();
933        assert_eq!(cells[0].node_type, "tableHeader");
934        assert_eq!(cells[1].node_type, "tableCell");
935    }
936
937    #[test]
938    fn blockquote_structure() {
939        let bq = AdfNode::blockquote(vec![AdfNode::paragraph(vec![AdfNode::text("quoted")])]);
940        assert_eq!(bq.node_type, "blockquote");
941        assert_eq!(bq.content.as_ref().unwrap()[0].node_type, "paragraph");
942    }
943
944    #[test]
945    fn hard_break_structure() {
946        let br = AdfNode::hard_break();
947        assert_eq!(br.node_type, "hardBreak");
948        assert!(br.content.is_none());
949        assert!(br.text.is_none());
950    }
951
952    #[test]
953    fn rule_structure() {
954        let rule = AdfNode::rule();
955        assert_eq!(rule.node_type, "rule");
956        assert!(rule.content.is_none());
957    }
958
959    // ── Additional node constructors ────────────────────────────────
960
961    #[test]
962    fn bullet_list_structure() {
963        let item = AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("item")])]);
964        let list = AdfNode::bullet_list(vec![item]);
965        assert_eq!(list.node_type, "bulletList");
966        assert_eq!(list.content.as_ref().unwrap().len(), 1);
967    }
968
969    #[test]
970    fn list_item_structure() {
971        let item = AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("text")])]);
972        assert_eq!(item.node_type, "listItem");
973    }
974
975    #[test]
976    fn inline_card_structure() {
977        let card = AdfNode::inline_card("https://example.com");
978        assert_eq!(card.node_type, "inlineCard");
979        assert_eq!(card.attrs.as_ref().unwrap()["url"], "https://example.com");
980    }
981
982    #[test]
983    fn task_list_structure() {
984        let item = AdfNode::task_item("TODO", vec![AdfNode::text("do this")]);
985        let list = AdfNode::task_list(vec![item]);
986        assert_eq!(list.node_type, "taskList");
987        assert!(list.attrs.as_ref().unwrap()["localId"].is_string());
988    }
989
990    #[test]
991    fn task_item_states() {
992        let todo = AdfNode::task_item("TODO", vec![]);
993        assert_eq!(todo.attrs.as_ref().unwrap()["state"], "TODO");
994
995        let done = AdfNode::task_item("DONE", vec![]);
996        assert_eq!(done.attrs.as_ref().unwrap()["state"], "DONE");
997    }
998
999    #[test]
1000    fn emoji_node() {
1001        let node = AdfNode::emoji(":thumbsup:");
1002        assert_eq!(node.node_type, "emoji");
1003        assert_eq!(node.attrs.as_ref().unwrap()["shortName"], ":thumbsup:");
1004    }
1005
1006    #[test]
1007    fn status_node() {
1008        let node = AdfNode::status("In Progress", "blue");
1009        assert_eq!(node.node_type, "status");
1010        assert_eq!(node.attrs.as_ref().unwrap()["text"], "In Progress");
1011        assert_eq!(node.attrs.as_ref().unwrap()["color"], "blue");
1012    }
1013
1014    #[test]
1015    fn date_node() {
1016        let node = AdfNode::date("1680307200000");
1017        assert_eq!(node.node_type, "date");
1018        assert_eq!(node.attrs.as_ref().unwrap()["timestamp"], "1680307200000");
1019    }
1020
1021    #[test]
1022    fn mention_node() {
1023        let node = AdfNode::mention("user-123", "Alice");
1024        assert_eq!(node.node_type, "mention");
1025        assert_eq!(node.attrs.as_ref().unwrap()["id"], "user-123");
1026        assert_eq!(node.attrs.as_ref().unwrap()["text"], "Alice");
1027    }
1028
1029    #[test]
1030    fn block_card_structure() {
1031        let card = AdfNode::block_card("https://example.com/page");
1032        assert_eq!(card.node_type, "blockCard");
1033        assert_eq!(
1034            card.attrs.as_ref().unwrap()["url"],
1035            "https://example.com/page"
1036        );
1037    }
1038
1039    #[test]
1040    fn embed_card_with_all_options() {
1041        let card = AdfNode::embed_card("https://example.com", Some("wide"), Some(800));
1042        let attrs = card.attrs.as_ref().unwrap();
1043        assert_eq!(attrs["url"], "https://example.com");
1044        assert_eq!(attrs["layout"], "wide");
1045        assert_eq!(attrs["width"], 800);
1046    }
1047
1048    #[test]
1049    fn embed_card_minimal() {
1050        let card = AdfNode::embed_card("https://example.com", None, None);
1051        let attrs = card.attrs.as_ref().unwrap();
1052        assert_eq!(attrs["url"], "https://example.com");
1053        assert!(attrs.get("layout").is_none());
1054        assert!(attrs.get("width").is_none());
1055    }
1056
1057    #[test]
1058    fn panel_structure() {
1059        let panel = AdfNode::panel(
1060            "info",
1061            vec![AdfNode::paragraph(vec![AdfNode::text("note")])],
1062        );
1063        assert_eq!(panel.node_type, "panel");
1064        assert_eq!(panel.attrs.as_ref().unwrap()["panelType"], "info");
1065    }
1066
1067    #[test]
1068    fn expand_with_title() {
1069        let node = AdfNode::expand(Some("Details"), vec![AdfNode::paragraph(vec![])]);
1070        assert_eq!(node.node_type, "expand");
1071        assert_eq!(node.attrs.as_ref().unwrap()["title"], "Details");
1072    }
1073
1074    #[test]
1075    fn expand_without_title() {
1076        let node = AdfNode::expand(None, vec![AdfNode::paragraph(vec![])]);
1077        assert_eq!(node.node_type, "expand");
1078        assert!(node.attrs.is_none());
1079    }
1080
1081    #[test]
1082    fn nested_expand_structure() {
1083        let node = AdfNode::nested_expand(Some("Inner"), vec![]);
1084        assert_eq!(node.node_type, "nestedExpand");
1085        assert_eq!(node.attrs.as_ref().unwrap()["title"], "Inner");
1086    }
1087
1088    #[test]
1089    fn layout_section_and_column() {
1090        let col = AdfNode::layout_column(50.0, vec![AdfNode::paragraph(vec![])]);
1091        assert_eq!(col.node_type, "layoutColumn");
1092        assert_eq!(col.attrs.as_ref().unwrap()["width"], 50.0);
1093
1094        let section = AdfNode::layout_section(vec![col]);
1095        assert_eq!(section.node_type, "layoutSection");
1096    }
1097
1098    #[test]
1099    fn decision_list_and_item() {
1100        let item = AdfNode::decision_item("DECIDED", vec![AdfNode::text("yes")]);
1101        assert_eq!(item.attrs.as_ref().unwrap()["state"], "DECIDED");
1102
1103        let list = AdfNode::decision_list(vec![item]);
1104        assert_eq!(list.node_type, "decisionList");
1105    }
1106
1107    #[test]
1108    fn extension_with_params() {
1109        let node = AdfNode::extension(
1110            "com.atlassian.jira",
1111            "issue-list",
1112            Some(serde_json::json!({"jql": "project = PROJ"})),
1113        );
1114        assert_eq!(node.node_type, "extension");
1115        let attrs = node.attrs.as_ref().unwrap();
1116        assert_eq!(attrs["extensionType"], "com.atlassian.jira");
1117        assert_eq!(attrs["parameters"]["jql"], "project = PROJ");
1118    }
1119
1120    #[test]
1121    fn extension_without_params() {
1122        let node = AdfNode::extension("com.atlassian.jira", "issue-list", None);
1123        let attrs = node.attrs.as_ref().unwrap();
1124        assert!(attrs.get("parameters").is_none());
1125    }
1126
1127    #[test]
1128    fn bodied_extension_structure() {
1129        let node = AdfNode::bodied_extension(
1130            "com.atlassian.jira",
1131            "issue-list",
1132            vec![AdfNode::paragraph(vec![AdfNode::text("body")])],
1133        );
1134        assert_eq!(node.node_type, "bodiedExtension");
1135        assert!(node.content.is_some());
1136    }
1137
1138    #[test]
1139    fn inline_extension_structure() {
1140        let node = AdfNode::inline_extension("com.test", "inline-key", Some("fallback"));
1141        assert_eq!(node.node_type, "inlineExtension");
1142        assert_eq!(node.text.as_deref(), Some("fallback"));
1143    }
1144
1145    #[test]
1146    fn inline_extension_no_fallback() {
1147        let node = AdfNode::inline_extension("com.test", "inline-key", None);
1148        assert!(node.text.is_none());
1149    }
1150
1151    #[test]
1152    fn table_with_attrs_structure() {
1153        let row = AdfNode::table_row(vec![]);
1154        let table = AdfNode::table_with_attrs(
1155            vec![row],
1156            serde_json::json!({"isNumberColumnEnabled": true, "layout": "default"}),
1157        );
1158        assert_eq!(table.node_type, "table");
1159        assert_eq!(table.attrs.as_ref().unwrap()["isNumberColumnEnabled"], true);
1160    }
1161
1162    #[test]
1163    fn table_header_with_attrs_structure() {
1164        let header = AdfNode::table_header_with_attrs(
1165            vec![AdfNode::paragraph(vec![AdfNode::text("H")])],
1166            serde_json::json!({"colspan": 2, "background": "#deebff"}),
1167        );
1168        assert_eq!(header.node_type, "tableHeader");
1169        assert_eq!(header.attrs.as_ref().unwrap()["colspan"], 2);
1170    }
1171
1172    #[test]
1173    fn table_cell_with_attrs_structure() {
1174        let cell = AdfNode::table_cell_with_attrs(
1175            vec![AdfNode::paragraph(vec![AdfNode::text("C")])],
1176            serde_json::json!({"rowspan": 3}),
1177        );
1178        assert_eq!(cell.node_type, "tableCell");
1179        assert_eq!(cell.attrs.as_ref().unwrap()["rowspan"], 3);
1180    }
1181
1182    // ── Additional mark constructors ────────────────────────────────
1183
1184    #[test]
1185    fn underline_mark() {
1186        let mark = AdfMark::underline();
1187        assert_eq!(mark.mark_type, "underline");
1188        assert!(mark.attrs.is_none());
1189    }
1190
1191    #[test]
1192    fn text_color_mark() {
1193        let mark = AdfMark::text_color("#ff0000");
1194        assert_eq!(mark.mark_type, "textColor");
1195        assert_eq!(mark.attrs.as_ref().unwrap()["color"], "#ff0000");
1196    }
1197
1198    #[test]
1199    fn background_color_mark() {
1200        let mark = AdfMark::background_color("#00ff00");
1201        assert_eq!(mark.mark_type, "backgroundColor");
1202        assert_eq!(mark.attrs.as_ref().unwrap()["color"], "#00ff00");
1203    }
1204
1205    #[test]
1206    fn subsup_mark() {
1207        let mark = AdfMark::subsup("sub");
1208        assert_eq!(mark.mark_type, "subsup");
1209        assert_eq!(mark.attrs.as_ref().unwrap()["type"], "sub");
1210    }
1211
1212    #[test]
1213    fn alignment_mark() {
1214        let mark = AdfMark::alignment("center");
1215        assert_eq!(mark.mark_type, "alignment");
1216        assert_eq!(mark.attrs.as_ref().unwrap()["align"], "center");
1217    }
1218
1219    #[test]
1220    fn indentation_mark() {
1221        let mark = AdfMark::indentation(2);
1222        assert_eq!(mark.mark_type, "indentation");
1223        assert_eq!(mark.attrs.as_ref().unwrap()["level"], 2);
1224    }
1225
1226    #[test]
1227    fn breakout_mark() {
1228        let mark = AdfMark::breakout("wide");
1229        assert_eq!(mark.mark_type, "breakout");
1230        assert_eq!(mark.attrs.as_ref().unwrap()["mode"], "wide");
1231    }
1232}