Skip to main content

madfun/adf/node/
mod.rs

1use super::{Mark, mark::LinkAttrs};
2use crate::Error;
3use buildstructor::{Builder, buildstructor};
4use serde::{Deserialize, Serialize};
5use std::fmt::Write;
6
7#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
8#[serde(tag = "type", rename_all = "camelCase")]
9pub enum Node {
10    /// <https://developer.atlassian.com/cloud/jira/platform/apis/document/nodes/codeBlock/>
11    ///
12    /// ```json
13    /// {
14    ///   "type": "codeBlock",
15    ///   "attrs": {
16    ///     "language": "javascript"
17    ///   },
18    ///   "content": [
19    ///     {
20    ///       "type": "text",
21    ///       "text": "var foo = {};\nvar bar = [];"
22    ///     }
23    ///   ]
24    /// }
25    CodeBlock {
26        #[serde(skip_serializing_if = "Option::is_none")]
27        attrs: Option<CodeBlockAttrs>,
28
29        #[serde(skip_serializing_if = "Vec::is_empty")]
30        content: Vec<Self>,
31    },
32
33    /// <https://developer.atlassian.com/cloud/jira/platform/apis/document/nodes/doc/>
34    ///
35    /// ```json
36    /// {
37    ///   "version": 1,
38    ///   "type": "doc",
39    ///   "content": [
40    ///     {
41    ///       "type": "paragraph",
42    ///       "content": [
43    ///         {
44    ///           "type": "text",
45    ///           "text": "Hello world"
46    ///         }
47    ///       ]
48    ///     }
49    ///   ]
50    /// }
51    /// ```
52    Doc {
53        version: u64,
54        content: Vec<Self>,
55    },
56
57    /// <https://developer.atlassian.com/cloud/jira/platform/apis/document/nodes/heading/>
58    ///
59    /// ```json
60    /// {
61    ///   "type": "heading",
62    ///   "attrs": {
63    ///     "level": 1
64    ///   },
65    ///   "content": [
66    ///     {
67    ///       "type": "text",
68    ///       "text": "Heading 1"
69    ///     }
70    ///   ]
71    /// }
72    /// ```
73    Heading {
74        content: Vec<Self>,
75        attrs: HeadingAttrs,
76    },
77
78    /// <https://developer.atlassian.com/cloud/jira/platform/apis/document/nodes/paragraph/>
79    ///
80    /// ```json
81    /// {
82    ///   "type": "paragraph",
83    ///   "content": [
84    ///     {
85    ///       "type": "text",
86    ///       "text": "Hello world"
87    ///     }
88    ///   ]
89    /// }
90    /// ```
91    Paragraph {
92        #[serde(skip_serializing_if = "Option::is_none")]
93        attrs: Option<ParagraphAttrs>,
94        content: Vec<Self>,
95    },
96
97    /// <https://developer.atlassian.com/cloud/jira/platform/apis/document/nodes/text/>
98    ///
99    /// ```json
100    /// {
101    ///   "type": "text",
102    ///   "text": "Hello world"
103    /// }
104    /// ```
105    Text {
106        text: String,
107        #[serde(default, skip_serializing_if = "Vec::is_empty")]
108        marks: Vec<Mark>,
109    },
110}
111
112#[buildstructor]
113impl Node {
114    /// Create a new builder for a CodeBlock discriminant.
115    #[must_use]
116    #[builder(entry = "codeblock")]
117    pub fn new_codeblock(
118        content: Vec<Node>,
119        attrs: Option<CodeBlockAttrs>,
120    ) -> Self {
121        Self::CodeBlock {
122            attrs,
123            content,
124        }
125    }
126
127    /// Create a new builder for a Doc discriminant.
128    #[must_use]
129    #[builder(entry = "doc")]
130    pub fn new_doc(content: Vec<Node>) -> Self {
131        Self::Doc {
132            content,
133            version: 1,
134        }
135    }
136
137    /// Create a new builder for a Heading discriminant.
138    #[must_use]
139    #[builder(entry = "heading")]
140    pub fn new_heading(content: Vec<Node>, attrs: HeadingAttrs) -> Self {
141        Self::Heading {
142            attrs,
143            content,
144        }
145    }
146
147    /// Create a new builder for a Paragraph discriminant.
148    #[must_use]
149    #[builder(entry = "paragraph")]
150    pub fn new_paragraph(
151        content: Vec<Node>,
152        attrs: Option<ParagraphAttrs>,
153    ) -> Self {
154        Self::Paragraph {
155            attrs,
156            content,
157        }
158    }
159
160    /// Create a new builder for a Text discriminant.
161    #[must_use]
162    #[builder(entry = "text")]
163    pub fn new_text(text: String, marks: Vec<Mark>) -> Self {
164        Self::Text {
165            text,
166            marks,
167        }
168    }
169
170    /// # Errors
171    ///
172    /// Returns an error if writing to the buffer fails or the ADF is malformed
173    /// (nodes where they don't belong).
174    pub fn to_markdown(&self, mut buf: String) -> Result<String, Error> {
175        Ok(match self {
176            Node::CodeBlock {
177                attrs,
178                content,
179            } => {
180                writeln!(
181                    &mut buf,
182                    "```{}",
183                    attrs.as_ref().map_or("", |a| &*a.language),
184                )?;
185                for node in content {
186                    match node {
187                        Node::Text {
188                            text,
189                            marks,
190                        } => {
191                            if !marks.is_empty() {
192                                return Err(Error::InvalidNode);
193                            }
194
195                            writeln!(&mut buf, "{text}",)?;
196                        },
197                        _ => return Err(Error::InvalidNode),
198                    }
199                }
200
201                // Write the closing backticks.
202                writeln!(&mut buf, "```\n")?;
203
204                buf
205            },
206            Node::Doc {
207                content,
208                ..
209            } => {
210                for node in content {
211                    buf = node.to_markdown(buf)?;
212                }
213
214                buf
215            },
216            Node::Heading {
217                content,
218                attrs,
219            } => {
220                write!(&mut buf, "{}", "#".repeat(attrs.level as usize))?;
221
222                for node in content {
223                    buf = node.to_markdown(buf)?;
224                }
225
226                write!(&mut buf, "\n\n")?;
227
228                buf
229            },
230            Node::Paragraph {
231                content,
232                ..
233            } => {
234                for node in content {
235                    buf = node.to_markdown(buf)?;
236                }
237
238                write!(&mut buf, "\n\n")?;
239
240                buf
241            },
242            Node::Text {
243                text,
244                marks,
245            } => {
246                // Go through any marks and print the corresponding opening
247                // character.
248                for mark in marks {
249                    match mark {
250                        Mark::Code => write!(&mut buf, "`")?,
251                        Mark::Link {
252                            ..
253                        } => {
254                            write!(&mut buf, "[")?;
255                        },
256                    }
257                }
258
259                // Print the main, actual content.
260                write!(&mut buf, "{text}")?;
261
262                // Go through the marks again and print any corresponding
263                // closing characters. We go through in reverse order to
264                // (hopefully) produce sane nested markup.
265                for mark in marks.iter().rev() {
266                    match mark {
267                        Mark::Code => write!(&mut buf, "`")?,
268                        Mark::Link {
269                            attrs:
270                                LinkAttrs {
271                                    href,
272                                    title,
273                                    ..
274                                },
275                        } => {
276                            write!(&mut buf, "]({href}")?;
277
278                            if let Some(title) = title {
279                                write!(&mut buf, " \"{title}\"")?;
280                            }
281
282                            write!(&mut buf, ")")?;
283                        },
284                    }
285                }
286
287                buf
288            },
289        })
290    }
291}
292
293#[derive(Builder, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
294pub struct ParagraphAttrs {
295    #[serde(rename = "locallid")]
296    local_id: String,
297}
298
299#[derive(Builder, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
300pub struct CodeBlockAttrs {
301    language: String,
302}
303
304#[derive(Builder, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
305pub struct HeadingAttrs {
306    level: u8,
307
308    #[serde(rename = "locallid")]
309    local_id: Option<String>,
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use serde_json::json;
316
317    mod codeblock {
318        use super::*;
319
320        mod serialize {
321            use super::*;
322            use pretty_assertions::assert_eq;
323
324            #[test]
325            fn test_empty() {
326                // Empty of content.
327                let adf = serde_json::to_value(Node::CodeBlock {
328                    attrs: None,
329                    content: vec![],
330                })
331                .unwrap();
332                assert_eq!(
333                    adf,
334                    json!({
335                        "type": "codeBlock",
336                    }),
337                    "{adf:?}"
338                );
339            }
340
341            #[test]
342            fn test_content() {
343                // With content.
344                let adf = serde_json::to_value(Node::CodeBlock {
345                    attrs: None,
346                    content: vec![Node::Text {
347                        text: "Hello world".into(),
348                        marks: vec![],
349                    }],
350                })
351                .unwrap();
352                assert_eq!(
353                    adf,
354                    json!({
355                        "type": "codeBlock",
356                        "content": [
357                          {
358                            "type": "text",
359                            "text": "Hello world"
360                          }
361                        ]
362                    }),
363                    "{adf:?}"
364                );
365            }
366
367            #[test]
368            fn test_with_attrs() {
369                let adf = serde_json::to_value(Node::CodeBlock {
370                    attrs: Some(CodeBlockAttrs {
371                        language: "foobar".into(),
372                    }),
373                    content: vec![],
374                })
375                .unwrap();
376                assert_eq!(
377                    adf,
378                    json!({
379                        "attrs": {
380                            "language": "foobar",
381                        },
382                        "type": "codeBlock",
383                    }),
384                    "{adf:?}"
385                );
386            }
387        }
388    }
389
390    mod doc {
391        use super::*;
392
393        mod serialize {
394            use super::*;
395            use pretty_assertions::assert_eq;
396
397            #[test]
398            fn test_empty() {
399                // Empty of content.
400                let adf = serde_json::to_value(Node::Doc {
401                    version: 1,
402                    content: vec![],
403                })
404                .unwrap();
405                assert_eq!(
406                    adf,
407                    json!({
408                        "version": 1,
409                        "type": "doc",
410                        "content": []
411                    }),
412                    "{adf:?}"
413                );
414            }
415
416            #[test]
417            fn test_with_content() {
418                let adf = serde_json::to_value(Node::Doc {
419                    version: 1,
420                    content: vec![Node::Paragraph {
421                        attrs: None,
422                        content: vec![Node::Text {
423                            text: "Hello world".into(),
424                            marks: vec![],
425                        }],
426                    }],
427                })
428                .unwrap();
429                assert_eq!(
430                    adf,
431                    json!({
432                        "version": 1,
433                        "type": "doc",
434                        "content": [
435                          {
436                            "type": "paragraph",
437                            "content": [
438                              {
439                                "type": "text",
440                                "text": "Hello world"
441                              }
442                            ]
443                          }
444                        ]
445                    }),
446                    "{adf:?}"
447                );
448            }
449        }
450
451        mod markdown {
452            use super::*;
453            use pretty_assertions::assert_eq;
454
455            #[test]
456            fn test_roundtrip_in_doc() {
457                assert_eq!(
458                    crate::from_str(concat!(
459                        "Some paragraph text.\n",
460                        "```\nsome code here\n```\n\n",
461                        "And now some paragraph text with a ",
462                        "[link](https://example.com).",
463                    ),)
464                    .unwrap(),
465                    Node::doc()
466                        .content(vec![
467                            Node::Paragraph {
468                                attrs: None,
469                                content: vec![Node::Text {
470                                    text: "Some paragraph text.".into(),
471                                    marks: vec![],
472                                },],
473                            },
474                            Node::CodeBlock {
475                                attrs: None,
476                                content: vec![Node::Text {
477                                    text: "some code here".into(),
478                                    marks: vec![],
479                                },],
480                            },
481                            Node::Paragraph {
482                                attrs: None,
483                                content: vec![
484                                    Node::Text {
485                                        text: "And now some paragraph text with a ".into(),
486                                        marks: vec![],
487                                    },
488                                    Node::Text {
489                                        text: "link".into(),
490                                        marks: vec![
491                                            Mark::Link {
492                                                attrs: LinkAttrs {
493                                                    collection: None,
494                                                    href: "https://example.com".into(),
495                                                    id: None,
496                                                    occurrence_key: None,
497                                                    title: None,
498                                                },
499                                            },
500                                        ],
501                                    },
502                                    Node::Text {
503                                        text: ".".into(),
504                                        marks: vec![],
505                                    },
506                                ],
507                            },
508                        ])
509                        .build(),
510                );
511            }
512        }
513    }
514
515    mod paragraph {
516        use super::*;
517
518        mod serialize {
519            use super::*;
520            use pretty_assertions::assert_eq;
521
522            #[test]
523            fn test_empty() {
524                // Empty of content.
525                let adf = serde_json::to_value(Node::Paragraph {
526                    attrs: None,
527                    content: vec![],
528                })
529                .unwrap();
530                assert_eq!(
531                    adf,
532                    json!({
533                        "type": "paragraph",
534                        "content": []
535                    }),
536                    "{adf:?}"
537                );
538            }
539
540            #[test]
541            fn test_content() {
542                // With content.
543                let adf = serde_json::to_value(Node::Paragraph {
544                    attrs: None,
545                    content: vec![Node::Text {
546                        text: "Hello world".into(),
547                        marks: vec![],
548                    }],
549                })
550                .unwrap();
551                assert_eq!(
552                    adf,
553                    json!({
554                        "type": "paragraph",
555                        "content": [
556                          {
557                            "type": "text",
558                            "text": "Hello world"
559                          }
560                        ]
561                    }),
562                    "{adf:?}"
563                );
564            }
565
566            #[test]
567            fn test_with_attrs() {
568                let adf = serde_json::to_value(Node::Paragraph {
569                    attrs: Some(ParagraphAttrs {
570                        local_id: "foobar".into(),
571                    }),
572                    content: vec![],
573                })
574                .unwrap();
575                assert_eq!(
576                    adf,
577                    json!({
578                        "attrs": {
579                            "locallid": "foobar",
580                        },
581                        "type": "paragraph",
582                        "content": [],
583                    }),
584                    "{adf:?}"
585                );
586            }
587        }
588    }
589
590    mod to_markdown {
591        use super::*;
592
593        mod codeblock {
594            use super::*;
595            use crate::ToAdf;
596            use markdown::mdast;
597            use pretty_assertions::assert_eq;
598
599            #[test]
600            fn test_from_adf_no_lang() {
601                assert_eq!(
602                    crate::to_markdown(
603                        &Node::codeblock()
604                            .content_entry(
605                                Node::text().text("let a = 1;").build()
606                            )
607                            .build(),
608                    )
609                    .unwrap(),
610                    "```\nlet a = 1;\n```"
611                );
612            }
613
614            #[test]
615            fn test_from_adf_with_lang() {
616                assert_eq!(
617                    crate::to_markdown(
618                        &Node::codeblock()
619                            .content_entry(
620                                Node::text().text("let a = 1;").build()
621                            )
622                            .attrs(
623                                CodeBlockAttrs::builder()
624                                    .language("rust")
625                                    .build()
626                            )
627                            .build(),
628                    )
629                    .unwrap(),
630                    "```rust\nlet a = 1;\n```"
631                );
632            }
633
634            #[test]
635            fn test_from_mdast_no_lang() {
636                assert_eq!(
637                    crate::to_markdown(
638                        &mdast::Node::Code(mdast::Code {
639                            value: "let a = 1;".into(),
640                            position: None,
641                            lang: None,
642                            meta: None,
643                        })
644                        .to_adf()
645                    )
646                    .unwrap(),
647                    "```\nlet a = 1;\n```"
648                );
649            }
650
651            #[test]
652            fn test_from_mdast_with_lang() {
653                assert_eq!(
654                    crate::to_markdown(
655                        &mdast::Node::Code(mdast::Code {
656                            value: "let a = 1;".into(),
657                            position: None,
658                            lang: Some("rust".into()),
659                            meta: None,
660                        })
661                        .to_adf()
662                    )
663                    .unwrap(),
664                    "```rust\nlet a = 1;\n```"
665                );
666            }
667
668            #[test]
669            fn test_roundtrip_no_lang() {
670                assert_eq!(
671                    crate::to_markdown(
672                        &crate::from_str("```\nlet a = 1;\n```\n").unwrap()
673                    )
674                    .unwrap(),
675                    "```\nlet a = 1;\n```"
676                );
677            }
678
679            #[test]
680            fn test_roundtrip_with_lang() {
681                assert_eq!(
682                    crate::to_markdown(
683                        &crate::from_str("```rust\nlet a = 1;\n```").unwrap()
684                    )
685                    .unwrap(),
686                    "```rust\nlet a = 1;\n```"
687                );
688            }
689
690            #[test]
691            fn test_roundtrip_in_doc() {
692                assert_eq!(
693                    crate::to_markdown(
694                        &crate::from_str(concat!(
695                            "Some paragraph text.\n\n",
696                            "```\nsome code here\n```\n\n",
697                            "And now some paragraph text with a ",
698                            "[link](https://example.com).",
699                        ),)
700                        .unwrap()
701                    )
702                    .unwrap(),
703                    concat!(
704                        "Some paragraph text.\n\n",
705                        "```\nsome code here\n```\n\n",
706                        "And now some paragraph text with a ",
707                        "[link](https://example.com).",
708                    ),
709                );
710            }
711        }
712
713        mod text {
714            use super::*;
715            use pretty_assertions::assert_eq;
716
717            #[test]
718            fn test_plain() {
719                assert_eq!(
720                    crate::to_markdown(
721                        &Node::paragraph()
722                            .content_entry(
723                                Node::text().text("This is text.").build()
724                            )
725                            .build(),
726                    )
727                    .unwrap(),
728                    "This is text."
729                );
730            }
731
732            #[test]
733            fn test_link() {
734                assert_eq!(
735                    crate::to_markdown(
736                        &crate::from_str("[a link](https://example.com)")
737                            .unwrap()
738                    )
739                    .unwrap(),
740                    "[a link](https://example.com)"
741                );
742            }
743        }
744
745        mod paragraph {
746            use super::*;
747            use crate::ToAdf;
748            use markdown::mdast;
749            use pretty_assertions::assert_eq;
750
751            #[test]
752            fn test_from_adf_oneline() {
753                assert_eq!(
754                    crate::to_markdown(
755                        &Node::paragraph()
756                            .content_entry(
757                                Node::text().text("Have a paragraph.").build()
758                            )
759                            .build(),
760                    )
761                    .unwrap(),
762                    "Have a paragraph."
763                );
764            }
765
766            #[test]
767            fn test_from_adf_multiline() {
768                assert_eq!(
769                    crate::to_markdown(
770                        &Node::paragraph()
771                            .content_entry(
772                                Node::text().text("Have a\nparagraph").build()
773                            )
774                            .build(),
775                    )
776                    .unwrap(),
777                    "Have a\nparagraph"
778                );
779            }
780
781            #[test]
782            fn test_from_mdast() {
783                assert_eq!(
784                    crate::to_markdown(
785                        &mdast::Node::Paragraph(mdast::Paragraph {
786                            children: vec![mdast::Node::Text(mdast::Text {
787                                value: "This is a paragraph.".into(),
788                                position: None,
789                            })],
790                            position: None,
791                        })
792                        .to_adf()
793                    )
794                    .unwrap(),
795                    "This is a paragraph."
796                );
797            }
798
799            #[test]
800            fn test_roundtrip() {
801                assert_eq!(
802                    crate::to_markdown(
803                        &crate::from_str("Hello, world").unwrap()
804                    )
805                    .unwrap(),
806                    "Hello, world"
807                );
808            }
809
810            #[test]
811            fn test_roundtrip_multi_paragraph() {
812                assert_eq!(
813                    crate::to_markdown(
814                        &crate::from_str("Hello, world\n\nHi there").unwrap()
815                    )
816                    .unwrap(),
817                    "Hello, world\n\nHi there"
818                );
819            }
820        }
821    }
822}