Skip to main content

omni_dev/atlassian/
convert.rs

1//! Bidirectional conversion between markdown and Atlassian Document Format.
2//!
3//! Supports Tier 1 (standard GFM) constructs: headings, paragraphs, inline
4//! marks (bold, italic, code, strikethrough, links), images, lists, code
5//! blocks, blockquotes, horizontal rules, and tables.
6
7use anyhow::Result;
8use chrono::NaiveDate;
9use tracing::{debug, warn};
10
11use crate::atlassian::adf::{AdfDocument, AdfMark, AdfNode};
12use crate::atlassian::attrs::parse_attrs;
13use crate::atlassian::directive::{
14    is_container_close, try_parse_container_open, try_parse_inline_directive,
15    try_parse_leaf_directive,
16};
17
18// ── Markdown → ADF ──────────────────────────────────────────────────
19
20/// Converts a markdown string to an ADF document.
21pub fn markdown_to_adf(markdown: &str) -> Result<AdfDocument> {
22    debug!(
23        "markdown_to_adf: input {} bytes, {} lines",
24        markdown.len(),
25        markdown.lines().count()
26    );
27    let mut doc = AdfDocument::new();
28    let mut parser = MarkdownParser::new(markdown);
29    doc.content = parser.parse_blocks()?;
30    debug!(
31        "markdown_to_adf: produced {} top-level ADF nodes",
32        doc.content.len()
33    );
34    Ok(doc)
35}
36
37/// Line-oriented state machine for parsing markdown into ADF block nodes.
38struct MarkdownParser<'a> {
39    lines: Vec<&'a str>,
40    pos: usize,
41}
42
43impl<'a> MarkdownParser<'a> {
44    fn new(input: &'a str) -> Self {
45        Self {
46            lines: input.lines().collect(),
47            pos: 0,
48        }
49    }
50
51    fn at_end(&self) -> bool {
52        self.pos >= self.lines.len()
53    }
54
55    fn current_line(&self) -> &'a str {
56        self.lines[self.pos]
57    }
58
59    fn advance(&mut self) {
60        self.pos += 1;
61    }
62
63    /// Collects indented continuation lines produced by hardBreaks (issue #402).
64    ///
65    /// When `full_text` ends with a hardBreak marker (trailing backslash or
66    /// two trailing spaces), the next 2-space-indented line is appended as a
67    /// continuation of the same paragraph.  The joined text is later fed to
68    /// `parse_inline`, which converts the `\\\n` or `  \n` sequences back
69    /// into `hardBreak` nodes.
70    fn collect_hardbreak_continuations(&mut self, full_text: &mut String) {
71        while has_trailing_hard_break(full_text) && !self.at_end() {
72            let next = self.current_line();
73            // Skip indented mediaSingle lines (`![`) — they are block-level
74            // siblings, not paragraph continuations (issue #490).
75            if let Some(stripped) = next
76                .strip_prefix("  ")
77                .filter(|s| !s.trim_start().starts_with("!["))
78            {
79                full_text.push('\n');
80                full_text.push_str(stripped);
81                self.advance();
82                continue;
83            }
84            break;
85        }
86    }
87
88    fn parse_blocks(&mut self) -> Result<Vec<AdfNode>> {
89        let mut blocks = Vec::new();
90
91        while !self.at_end() {
92            let line = self.current_line();
93
94            if line.trim().is_empty() {
95                self.advance();
96                continue;
97            }
98
99            let mut node = if let Some(node) = self.try_heading() {
100                node
101            } else if let Some(node) = self.try_horizontal_rule() {
102                node
103            } else if let Some(node) = self.try_container_directive()? {
104                node
105            } else if let Some(node) = self.try_code_block()? {
106                node
107            } else if let Some(node) = self.try_table()? {
108                node
109            } else if let Some(node) = self.try_blockquote()? {
110                node
111            } else if let Some(node) = self.try_list()? {
112                node
113            } else if let Some(node) = self.try_leaf_directive() {
114                node
115            } else if let Some(node) = self.try_image() {
116                node
117            } else {
118                self.parse_paragraph()?
119            };
120
121            // Check for trailing block-level {attrs} (align, indent, breakout)
122            self.try_apply_block_attrs(&mut node);
123            blocks.push(node);
124        }
125
126        Ok(blocks)
127    }
128
129    fn try_heading(&mut self) -> Option<AdfNode> {
130        let line = self.current_line();
131        let trimmed = line.trim_start();
132
133        if !trimmed.starts_with('#') {
134            return None;
135        }
136
137        let level = trimmed.chars().take_while(|&c| c == '#').count();
138        if !(1..=6).contains(&level) || !trimmed[level..].starts_with(' ') {
139            return None;
140        }
141
142        let mut full_text = trimmed[level + 1..].to_string();
143        self.advance();
144        // Collect indented continuation lines produced by hardBreaks (issue #433).
145        self.collect_hardbreak_continuations(&mut full_text);
146        let inline_nodes = parse_inline(&full_text);
147
148        #[allow(clippy::cast_possible_truncation)]
149        Some(AdfNode::heading(level as u8, inline_nodes))
150    }
151
152    fn try_horizontal_rule(&mut self) -> Option<AdfNode> {
153        let line = self.current_line().trim();
154        let is_rule = (line.starts_with("---") && line.chars().all(|c| c == '-'))
155            || (line.starts_with("***") && line.chars().all(|c| c == '*'))
156            || (line.starts_with("___") && line.chars().all(|c| c == '_'));
157
158        if is_rule && line.len() >= 3 {
159            self.advance();
160            Some(AdfNode::rule())
161        } else {
162            None
163        }
164    }
165
166    fn try_code_block(&mut self) -> Result<Option<AdfNode>> {
167        let line = self.current_line();
168        if !line.starts_with("```") {
169            return Ok(None);
170        }
171
172        let language = line[3..].trim();
173        let language = if language == "\"\"" {
174            // Explicit empty language attr encoded as ```""
175            Some(String::new())
176        } else if language.is_empty() {
177            None
178        } else {
179            Some(language.to_string())
180        };
181
182        self.advance();
183        let mut code_lines = Vec::new();
184
185        while !self.at_end() {
186            let line = self.current_line();
187            if line.starts_with("```") {
188                self.advance();
189                break;
190            }
191            code_lines.push(line);
192            self.advance();
193        }
194
195        let code_text = code_lines.join("\n");
196
197        // If the language is "adf-unsupported", deserialize the JSON back to an AdfNode
198        if language.as_deref() == Some("adf-unsupported") {
199            if let Ok(node) = serde_json::from_str::<AdfNode>(&code_text) {
200                return Ok(Some(node));
201            }
202        }
203
204        Ok(Some(AdfNode::code_block(language.as_deref(), &code_text)))
205    }
206
207    fn try_blockquote(&mut self) -> Result<Option<AdfNode>> {
208        let line = self.current_line();
209        if !line.starts_with('>') {
210            return Ok(None);
211        }
212
213        let mut quote_lines = Vec::new();
214        while !self.at_end() {
215            let line = self.current_line();
216            if let Some(rest) = line.strip_prefix("> ") {
217                quote_lines.push(rest);
218                self.advance();
219            } else if let Some(rest) = line.strip_prefix('>') {
220                quote_lines.push(rest);
221                self.advance();
222            } else {
223                break;
224            }
225        }
226
227        let quote_text = quote_lines.join("\n");
228        let mut inner_parser = MarkdownParser::new(&quote_text);
229        let inner_blocks = inner_parser.parse_blocks()?;
230
231        Ok(Some(AdfNode::blockquote(inner_blocks)))
232    }
233
234    fn try_list(&mut self) -> Result<Option<AdfNode>> {
235        let line = self.current_line();
236        let trimmed = line.trim_start();
237
238        let is_bullet =
239            trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ");
240        let ordered_match = parse_ordered_list_marker(trimmed);
241
242        if !is_bullet && ordered_match.is_none() {
243            return Ok(None);
244        }
245
246        if is_bullet {
247            self.parse_bullet_list()
248        } else {
249            let start = ordered_match.map_or(1, |(n, _)| n);
250            self.parse_ordered_list(start)
251        }
252    }
253
254    fn parse_bullet_list(&mut self) -> Result<Option<AdfNode>> {
255        let mut items = Vec::new();
256        let mut is_task_list = false;
257
258        while !self.at_end() {
259            let line = self.current_line();
260            let trimmed = line.trim_start();
261
262            if !(trimmed.starts_with("- ")
263                || trimmed.starts_with("* ")
264                || trimmed.starts_with("+ "))
265            {
266                break;
267            }
268
269            let after_marker = trimmed[2..].trim_start();
270
271            // Detect task list items: - [ ] or - [x]
272            if let Some((state, text)) = try_parse_task_marker(after_marker) {
273                is_task_list = true;
274                self.advance();
275                // Collect hardBreak continuation lines so that a trailing
276                // {localId=…} on the last continuation line is found by
277                // extract_trailing_local_id (issue #507).
278                let mut full_text = text.to_string();
279                self.collect_hardbreak_continuations(&mut full_text);
280                let (item_text, local_id, para_local_id) = extract_trailing_local_id(&full_text);
281                let inline_nodes = parse_inline(item_text);
282                // If a paraLocalId marker is present the original ADF had a
283                // paragraph wrapper around the inline content — restore it
284                // so the round-trip is lossless (issue #478).
285                let content = if let Some(ref plid) = para_local_id {
286                    let mut para = AdfNode::paragraph(inline_nodes);
287                    if plid != "_" {
288                        para.attrs = Some(serde_json::json!({"localId": plid}));
289                    }
290                    vec![para]
291                } else {
292                    inline_nodes
293                };
294                let mut task = AdfNode::task_item(state, content);
295                // Override the placeholder localId if one was parsed
296                if let Some(id) = local_id {
297                    if let Some(ref mut attrs) = task.attrs {
298                        attrs["localId"] = serde_json::Value::String(id);
299                    }
300                }
301                // Collect indented sub-content (e.g. nested task lists
302                // from malformed ADF where taskItem contains taskItem
303                // children directly — issue #489).
304                let mut sub_lines: Vec<String> = Vec::new();
305                while !self.at_end() && self.current_line().starts_with("  ") {
306                    let stripped = &self.current_line()[2..];
307                    sub_lines.push(stripped.to_string());
308                    self.advance();
309                }
310                if !sub_lines.is_empty() {
311                    let sub_text = sub_lines.join("\n");
312                    let mut nested = MarkdownParser::new(&sub_text).parse_blocks()?;
313                    // When the task item has no inline text and its
314                    // sub-content is a single taskList, this is a
315                    // container taskItem from malformed ADF (issue #489).
316                    // Unwrap the taskList so the taskItem children sit
317                    // directly in the container, and drop the spurious
318                    // `state` attr that was injected by the checkbox
319                    // marker.
320                    let is_empty = task.content.as_ref().map_or(true, Vec::is_empty);
321                    if is_empty && nested.len() == 1 && nested[0].node_type == "taskList" {
322                        if let Some(task_items) = nested.remove(0).content {
323                            task.content = Some(task_items);
324                        }
325                        if let Some(ref mut attrs) = task.attrs {
326                            if let Some(obj) = attrs.as_object_mut() {
327                                obj.remove("state");
328                            }
329                        }
330                        items.push(task);
331                    } else {
332                        // Separate nested taskList nodes from other block
333                        // content.  Nested taskLists become sibling children
334                        // of the outer taskList rather than children of this
335                        // taskItem, matching ADF's representation of indented
336                        // sub-lists (issue #506).
337                        let mut sibling_task_lists = Vec::new();
338                        let mut child_nodes = Vec::new();
339                        for n in nested {
340                            if n.node_type == "taskList" {
341                                sibling_task_lists.push(n);
342                            } else {
343                                child_nodes.push(n);
344                            }
345                        }
346                        if !child_nodes.is_empty() {
347                            match task.content {
348                                Some(ref mut content) => content.append(&mut child_nodes),
349                                None => task.content = Some(child_nodes),
350                            }
351                        }
352                        items.push(task);
353                        items.append(&mut sibling_task_lists);
354                    }
355                } else {
356                    items.push(task);
357                }
358            } else {
359                let first_line = &trimmed[2..];
360                self.advance();
361                let mut full_text = first_line.to_string();
362                self.collect_hardbreak_continuations(&mut full_text);
363                let (item_text, local_id, para_local_id) = extract_trailing_local_id(&full_text);
364                // Collect indented sub-content lines (2-space prefix).
365                // This captures both nested lists and continuation
366                // paragraphs that belong to the same list item.
367                let mut sub_lines: Vec<String> = Vec::new();
368                while !self.at_end() {
369                    let next = self.current_line();
370                    if let Some(stripped) = next.strip_prefix("  ") {
371                        sub_lines.push(stripped.to_string());
372                        self.advance();
373                        continue;
374                    }
375                    break;
376                }
377                let item_content =
378                    parse_list_item_first_line(item_text, sub_lines, local_id, para_local_id)?;
379                items.push(item_content);
380            }
381        }
382
383        if items.is_empty() {
384            Ok(None)
385        } else if is_task_list {
386            Ok(Some(AdfNode::task_list(items)))
387        } else {
388            Ok(Some(AdfNode::bullet_list(items)))
389        }
390    }
391
392    fn parse_ordered_list(&mut self, start: u32) -> Result<Option<AdfNode>> {
393        let mut items = Vec::new();
394
395        while !self.at_end() {
396            let line = self.current_line();
397            let trimmed = line.trim_start();
398
399            if let Some((_, rest)) = parse_ordered_list_marker(trimmed) {
400                let first_line = rest.trim_start_matches(|c: char| c.is_ascii_whitespace());
401                self.advance();
402                let mut full_text = first_line.to_string();
403                self.collect_hardbreak_continuations(&mut full_text);
404                let (item_text, local_id, para_local_id) = extract_trailing_local_id(&full_text);
405                // Collect indented sub-content lines (2-space prefix).
406                let mut sub_lines: Vec<String> = Vec::new();
407                while !self.at_end() {
408                    let next = self.current_line();
409                    if let Some(stripped) = next.strip_prefix("  ") {
410                        sub_lines.push(stripped.to_string());
411                        self.advance();
412                        continue;
413                    }
414                    break;
415                }
416                let item_content =
417                    parse_list_item_first_line(item_text, sub_lines, local_id, para_local_id)?;
418                items.push(item_content);
419            } else {
420                break;
421            }
422        }
423
424        if items.is_empty() {
425            Ok(None)
426        } else {
427            Ok(Some(AdfNode::ordered_list(items, Some(start))))
428        }
429    }
430
431    fn try_apply_block_attrs(&mut self, node: &mut AdfNode) {
432        if self.at_end() {
433            return;
434        }
435        let line = self.current_line().trim();
436        if !line.starts_with('{') {
437            return;
438        }
439        let Some((_, attrs)) = parse_attrs(line, 0) else {
440            return;
441        };
442
443        let mut marks = Vec::new();
444        if let Some(align) = attrs.get("align") {
445            marks.push(AdfMark::alignment(align));
446        }
447        if let Some(indent) = attrs.get("indent") {
448            if let Ok(level) = indent.parse::<u32>() {
449                marks.push(AdfMark::indentation(level));
450            }
451        }
452        if let Some(mode) = attrs.get("breakout") {
453            let width = attrs
454                .get("breakoutWidth")
455                .and_then(|w| w.parse::<u32>().ok());
456            marks.push(AdfMark::breakout(mode, width));
457        }
458
459        // Parse localId from block attrs
460        let local_id = attrs.get("localId").map(str::to_string);
461
462        let has_attrs = !marks.is_empty() || local_id.is_some();
463        if has_attrs {
464            if !marks.is_empty() {
465                let existing = node.marks.get_or_insert_with(Vec::new);
466                existing.extend(marks);
467            }
468            if let Some(id) = local_id {
469                let node_attrs = node.attrs.get_or_insert_with(|| serde_json::json!({}));
470                node_attrs["localId"] = serde_json::Value::String(id);
471            }
472            self.advance(); // consume the attrs line
473        }
474    }
475
476    fn try_container_directive(&mut self) -> Result<Option<AdfNode>> {
477        let line = self.current_line();
478        let Some((d, colon_count)) = try_parse_container_open(line) else {
479            return Ok(None);
480        };
481        self.advance(); // past opening fence
482
483        // Collect inner lines until the matching close fence, tracking nesting
484        let mut inner_lines = Vec::new();
485        let mut depth: usize = 0;
486        while !self.at_end() {
487            let current = self.current_line();
488            if try_parse_container_open(current).is_some() {
489                depth += 1;
490            } else if depth == 0 && is_container_close(current, colon_count) {
491                self.advance(); // past closing fence
492                break;
493            } else if depth > 0 && is_container_close(current, 3) {
494                depth -= 1;
495            }
496            inner_lines.push(current.to_string());
497            self.advance();
498        }
499
500        let inner_text = inner_lines.join("\n");
501
502        let node = match d.name.as_str() {
503            "panel" => {
504                let panel_type = d
505                    .attrs
506                    .as_ref()
507                    .and_then(|a| a.get("type"))
508                    .unwrap_or("info");
509                let inner_blocks = MarkdownParser::new(&inner_text).parse_blocks()?;
510                let mut node = AdfNode::panel(panel_type, inner_blocks);
511                // Pass through custom panel attrs (icon, color)
512                if let Some(ref attrs) = d.attrs {
513                    if let Some(ref mut node_attrs) = node.attrs {
514                        if let Some(icon) = attrs.get("icon") {
515                            node_attrs["panelIcon"] = serde_json::Value::String(icon.to_string());
516                        }
517                        if let Some(color) = attrs.get("color") {
518                            node_attrs["panelColor"] = serde_json::Value::String(color.to_string());
519                        }
520                    }
521                }
522                node
523            }
524            "expand" => {
525                let title = d.attrs.as_ref().and_then(|a| a.get("title"));
526                let inner_blocks = MarkdownParser::new(&inner_text).parse_blocks()?;
527                let mut node = AdfNode::expand(title, inner_blocks);
528                pass_through_expand_params(&d.attrs, &mut node);
529                node
530            }
531            "nested-expand" => {
532                let title = d.attrs.as_ref().and_then(|a| a.get("title"));
533                let inner_blocks = MarkdownParser::new(&inner_text).parse_blocks()?;
534                let mut node = AdfNode::nested_expand(title, inner_blocks);
535                pass_through_expand_params(&d.attrs, &mut node);
536                node
537            }
538            "layout" => {
539                // Parse inner content looking for :::column sub-containers
540                let columns = self.parse_layout_columns(&inner_text)?;
541                AdfNode::layout_section(columns)
542            }
543            "decisions" => {
544                let items = parse_decision_items(&inner_text);
545                AdfNode::decision_list(items)
546            }
547            "table" => {
548                let rows = self.parse_directive_table_rows(&inner_text)?;
549                let mut table_attrs = serde_json::json!({});
550                if let Some(ref attrs) = d.attrs {
551                    if let Some(layout) = attrs.get("layout") {
552                        table_attrs["layout"] = serde_json::Value::String(layout.to_string());
553                    }
554                    if attrs.has_flag("numbered") {
555                        table_attrs["isNumberColumnEnabled"] = serde_json::json!(true);
556                    } else if attrs.get("numbered") == Some("false") {
557                        table_attrs["isNumberColumnEnabled"] = serde_json::json!(false);
558                    }
559                    if let Some(tw) = attrs.get("width") {
560                        if let Ok(w) = tw.parse::<f64>() {
561                            table_attrs["width"] = serde_json::json!(w);
562                        }
563                    }
564                    if let Some(local_id) = attrs.get("localId") {
565                        table_attrs["localId"] = serde_json::Value::String(local_id.to_string());
566                    }
567                }
568                if table_attrs == serde_json::json!({}) {
569                    AdfNode::table(rows)
570                } else {
571                    AdfNode::table_with_attrs(rows, table_attrs)
572                }
573            }
574            "extension" => {
575                let ext_type = d.attrs.as_ref().and_then(|a| a.get("type")).unwrap_or("");
576                let ext_key = d.attrs.as_ref().and_then(|a| a.get("key")).unwrap_or("");
577                let inner_blocks = MarkdownParser::new(&inner_text).parse_blocks()?;
578                let mut node = AdfNode::bodied_extension(ext_type, ext_key, inner_blocks);
579                if let (Some(ref dir_attrs), Some(ref mut node_attrs)) = (&d.attrs, &mut node.attrs)
580                {
581                    if let Some(layout) = dir_attrs.get("layout") {
582                        node_attrs["layout"] = serde_json::Value::String(layout.to_string());
583                    }
584                    if let Some(local_id) = dir_attrs.get("localId") {
585                        node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
586                    }
587                    if let Some(params_str) = dir_attrs.get("params") {
588                        if let Ok(params_val) =
589                            serde_json::from_str::<serde_json::Value>(params_str)
590                        {
591                            node_attrs["parameters"] = params_val;
592                        }
593                    }
594                }
595                node
596            }
597            _ => return Ok(None),
598        };
599
600        Ok(Some(node))
601    }
602
603    fn parse_layout_columns(&self, inner_text: &str) -> Result<Vec<AdfNode>> {
604        let mut columns = Vec::new();
605        let mut current_column_lines: Vec<String> = Vec::new();
606        let mut current_width: f64 = 50.0;
607        let mut current_dir_attrs: Option<crate::atlassian::attrs::Attrs> = None;
608        let mut in_column = false;
609        let mut depth: usize = 0;
610
611        let lines: Vec<&str> = inner_text.lines().collect();
612        let mut i = 0;
613
614        while i < lines.len() {
615            let line = lines[i];
616            if let Some((col_d, _)) = try_parse_container_open(line) {
617                if col_d.name == "column" && depth == 0 {
618                    // Flush previous column
619                    if in_column && !current_column_lines.is_empty() {
620                        let col_text = current_column_lines.join("\n");
621                        let blocks = MarkdownParser::new(&col_text).parse_blocks()?;
622                        let mut col = AdfNode::layout_column(current_width, blocks);
623                        pass_through_local_id(&current_dir_attrs, &mut col);
624                        columns.push(col);
625                        current_column_lines.clear();
626                    }
627                    current_width = col_d
628                        .attrs
629                        .as_ref()
630                        .and_then(|a| a.get("width"))
631                        .and_then(|w| w.parse::<f64>().ok())
632                        .unwrap_or(50.0);
633                    current_dir_attrs = col_d.attrs;
634                    in_column = true;
635                    i += 1;
636                    continue;
637                }
638                if in_column {
639                    depth += 1;
640                }
641            }
642            if in_column && is_container_close(line, 3) {
643                if depth > 0 {
644                    depth -= 1;
645                    current_column_lines.push(line.to_string());
646                    i += 1;
647                    continue;
648                }
649                // End of column
650                let col_text = current_column_lines.join("\n");
651                let blocks = MarkdownParser::new(&col_text).parse_blocks()?;
652                let mut col = AdfNode::layout_column(current_width, blocks);
653                pass_through_local_id(&current_dir_attrs, &mut col);
654                columns.push(col);
655                current_column_lines.clear();
656                current_dir_attrs = None;
657                in_column = false;
658                i += 1;
659                continue;
660            }
661            if in_column {
662                current_column_lines.push(line.to_string());
663            }
664            i += 1;
665        }
666
667        // Flush last column if no closing fence
668        if in_column && !current_column_lines.is_empty() {
669            let col_text = current_column_lines.join("\n");
670            let blocks = MarkdownParser::new(&col_text).parse_blocks()?;
671            let mut col = AdfNode::layout_column(current_width, blocks);
672            pass_through_local_id(&current_dir_attrs, &mut col);
673            columns.push(col);
674        }
675
676        Ok(columns)
677    }
678
679    /// Parses `:::tr` / `:::th` / `:::td` sub-containers inside a `:::table` directive.
680    fn parse_directive_table_rows(&self, inner_text: &str) -> Result<Vec<AdfNode>> {
681        debug!(
682            "parse_directive_table_rows: {} lines of inner text",
683            inner_text.lines().count()
684        );
685        let mut rows = Vec::new();
686        let lines: Vec<&str> = inner_text.lines().collect();
687        let mut i = 0;
688
689        while i < lines.len() {
690            let line = lines[i];
691            if let Some((d, _)) = try_parse_container_open(line) {
692                if d.name == "tr" {
693                    let tr_attrs = d.attrs.clone();
694                    i += 1;
695                    let (mut row, next_i) = self.parse_directive_table_row(&lines, i)?;
696                    // Pass through localId from :::tr{localId=...}
697                    if let Some(ref attrs) = tr_attrs {
698                        if let Some(local_id) = attrs.get("localId") {
699                            let row_attrs = row.attrs.get_or_insert_with(|| serde_json::json!({}));
700                            row_attrs["localId"] = serde_json::Value::String(local_id.to_string());
701                        }
702                    }
703                    rows.push(row);
704                    i = next_i;
705                    continue;
706                }
707                if d.name == "caption" {
708                    let dir_attrs = d.attrs.clone();
709                    i += 1;
710                    let mut caption_lines = Vec::new();
711                    while i < lines.len() {
712                        if is_container_close(lines[i], 3) {
713                            i += 1;
714                            break;
715                        }
716                        caption_lines.push(lines[i]);
717                        i += 1;
718                    }
719                    let caption_text = caption_lines.join("\n");
720                    let inline_nodes = parse_inline(&caption_text);
721                    let mut caption = AdfNode::caption(inline_nodes);
722                    pass_through_local_id(&dir_attrs, &mut caption);
723                    rows.push(caption);
724                    continue;
725                }
726            }
727            i += 1;
728        }
729
730        Ok(rows)
731    }
732
733    /// Parses cells within a `:::tr` container until its closing fence.
734    fn parse_directive_table_row(&self, lines: &[&str], start: usize) -> Result<(AdfNode, usize)> {
735        let mut cells = Vec::new();
736        let mut i = start;
737        let mut depth: usize = 0;
738
739        while i < lines.len() {
740            let line = lines[i];
741            if is_container_close(line, 3) {
742                if depth == 0 {
743                    // End of :::tr
744                    i += 1;
745                    break;
746                }
747                depth -= 1;
748                i += 1;
749                continue;
750            }
751            if let Some((d, _)) = try_parse_container_open(line) {
752                if depth == 0 && (d.name == "th" || d.name == "td") {
753                    let is_header = d.name == "th";
754                    let cell_attrs = d.attrs.clone();
755                    i += 1;
756                    let (cell, next_i) =
757                        self.parse_directive_table_cell(lines, i, is_header, cell_attrs)?;
758                    cells.push(cell);
759                    i = next_i;
760                    continue;
761                }
762                depth += 1;
763            }
764            i += 1;
765        }
766
767        if cells.is_empty() {
768            let context = lines[start.saturating_sub(1)..lines.len().min(start + 3)].to_vec();
769            warn!(
770                "Directive table row at line {start} has no cells — \
771                 Confluence requires at least one. Nearby lines: {context:?}"
772            );
773        }
774        debug!("Parsed directive table row: {} cells", cells.len());
775
776        Ok((AdfNode::table_row(cells), i))
777    }
778
779    /// Parses the content of a `:::th` or `:::td` cell until its closing fence.
780    fn parse_directive_table_cell(
781        &self,
782        lines: &[&str],
783        start: usize,
784        is_header: bool,
785        cell_attrs: Option<crate::atlassian::attrs::Attrs>,
786    ) -> Result<(AdfNode, usize)> {
787        let mut cell_lines = Vec::new();
788        let mut i = start;
789        let mut depth: usize = 0;
790
791        while i < lines.len() {
792            let line = lines[i];
793            if try_parse_container_open(line).is_some() {
794                depth += 1;
795            } else if is_container_close(line, 3) {
796                if depth == 0 {
797                    i += 1;
798                    break;
799                }
800                depth -= 1;
801            }
802            cell_lines.push(line.to_string());
803            i += 1;
804        }
805
806        let cell_text = cell_lines.join("\n");
807        let blocks = MarkdownParser::new(&cell_text).parse_blocks()?;
808
809        let adf_attrs = cell_attrs.as_ref().map(build_cell_attrs);
810        let cell_marks = cell_attrs
811            .as_ref()
812            .map(build_border_marks)
813            .unwrap_or_default();
814
815        let cell = if cell_marks.is_empty() {
816            if is_header {
817                if let Some(attrs) = adf_attrs {
818                    AdfNode::table_header_with_attrs(blocks, attrs)
819                } else {
820                    AdfNode::table_header(blocks)
821                }
822            } else if let Some(attrs) = adf_attrs {
823                AdfNode::table_cell_with_attrs(blocks, attrs)
824            } else {
825                AdfNode::table_cell(blocks)
826            }
827        } else if is_header {
828            AdfNode::table_header_with_attrs_and_marks(blocks, adf_attrs, cell_marks)
829        } else {
830            AdfNode::table_cell_with_attrs_and_marks(blocks, adf_attrs, cell_marks)
831        };
832
833        Ok((cell, i))
834    }
835
836    fn try_leaf_directive(&mut self) -> Option<AdfNode> {
837        let line = self.current_line();
838        let d = try_parse_leaf_directive(line)?;
839
840        let node = match d.name.as_str() {
841            "card" => {
842                let url = d.content.as_deref().unwrap_or("");
843                let mut node = AdfNode::block_card(url);
844                // Pass through layout/width attrs
845                if let Some(ref attrs) = d.attrs {
846                    if let Some(ref mut node_attrs) = node.attrs {
847                        if let Some(layout) = attrs.get("layout") {
848                            node_attrs["layout"] = serde_json::Value::String(layout.to_string());
849                        }
850                        if let Some(width) = attrs.get("width") {
851                            if let Ok(w) = width.parse::<u64>() {
852                                node_attrs["width"] = serde_json::json!(w);
853                            }
854                        }
855                    }
856                }
857                node
858            }
859            "embed" => {
860                let url = d.content.as_deref().unwrap_or("");
861                let layout = d.attrs.as_ref().and_then(|a| a.get("layout"));
862                let original_height = d
863                    .attrs
864                    .as_ref()
865                    .and_then(|a| a.get("originalHeight"))
866                    .and_then(|v| v.parse::<f64>().ok());
867                let width = d
868                    .attrs
869                    .as_ref()
870                    .and_then(|a| a.get("width"))
871                    .and_then(|w| w.parse::<f64>().ok());
872                AdfNode::embed_card(url, layout, original_height, width)
873            }
874            "extension" => {
875                let ext_type = d.attrs.as_ref().and_then(|a| a.get("type")).unwrap_or("");
876                let ext_key = d.attrs.as_ref().and_then(|a| a.get("key")).unwrap_or("");
877                let params = d
878                    .attrs
879                    .as_ref()
880                    .and_then(|a| a.get("params"))
881                    .and_then(|p| serde_json::from_str(p).ok());
882                let mut node = AdfNode::extension(ext_type, ext_key, params);
883                if let (Some(ref dir_attrs), Some(ref mut node_attrs)) = (&d.attrs, &mut node.attrs)
884                {
885                    if let Some(layout) = dir_attrs.get("layout") {
886                        node_attrs["layout"] = serde_json::Value::String(layout.to_string());
887                    }
888                    if let Some(local_id) = dir_attrs.get("localId") {
889                        node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
890                    }
891                }
892                node
893            }
894            "paragraph" => {
895                let mut node = if let Some(ref text) = d.content {
896                    AdfNode::paragraph(parse_inline(text))
897                } else {
898                    AdfNode::paragraph(vec![])
899                };
900                pass_through_local_id(&d.attrs, &mut node);
901                node
902            }
903            _ => return None,
904        };
905
906        self.advance();
907        Some(node)
908    }
909
910    fn try_image(&mut self) -> Option<AdfNode> {
911        let line = self.current_line().trim();
912        let mut node = try_parse_media_single_from_line(line)?;
913        self.advance();
914
915        // Check for a trailing :::caption directive
916        if !self.at_end() {
917            if let Some((d, _)) = try_parse_container_open(self.current_line()) {
918                if d.name == "caption" {
919                    let dir_attrs = d.attrs;
920                    self.advance(); // past :::caption
921                    let mut caption_lines = Vec::new();
922                    while !self.at_end() {
923                        if is_container_close(self.current_line(), 3) {
924                            self.advance(); // past :::
925                            break;
926                        }
927                        caption_lines.push(self.current_line());
928                        self.advance();
929                    }
930                    let caption_text = caption_lines.join("\n");
931                    let inline_nodes = parse_inline(&caption_text);
932                    let mut caption = AdfNode::caption(inline_nodes);
933                    pass_through_local_id(&dir_attrs, &mut caption);
934                    if let Some(ref mut content) = node.content {
935                        content.push(caption);
936                    }
937                }
938            }
939        }
940
941        Some(node)
942    }
943
944    fn try_table(&mut self) -> Result<Option<AdfNode>> {
945        let line = self.current_line();
946        if !line.contains('|') || !line.trim_start().starts_with('|') {
947            return Ok(None);
948        }
949
950        // Peek ahead to check for a separator row (indicates a table)
951        if self.pos + 1 >= self.lines.len() {
952            return Ok(None);
953        }
954        let next_line = self.lines[self.pos + 1];
955        if !is_table_separator(next_line) {
956            return Ok(None);
957        }
958
959        // Parse header row
960        let header_cells = parse_table_row(line);
961        self.advance(); // skip header
962
963        // Parse separator row for column alignment
964        let sep_line = self.current_line();
965        let alignments = parse_table_alignments(sep_line);
966        self.advance(); // skip separator
967
968        let mut rows = Vec::new();
969
970        // Header row — parse cell attrs and apply column alignment
971        let header_adf_cells: Vec<AdfNode> = header_cells
972            .iter()
973            .enumerate()
974            .map(|(col_idx, cell)| {
975                let (cell_text, cell_attrs) = extract_cell_attrs(cell);
976                let mut para = AdfNode::paragraph(parse_inline(&cell_text));
977                apply_column_alignment(&mut para, alignments.get(col_idx).copied().flatten());
978                if let Some(attrs) = cell_attrs {
979                    AdfNode::table_header_with_attrs(vec![para], attrs)
980                } else {
981                    AdfNode::table_header(vec![para])
982                }
983            })
984            .collect();
985        if header_adf_cells.is_empty() {
986            warn!(
987                "Pipe table header row at line {} has no cells",
988                self.pos - 1
989            );
990        }
991        rows.push(AdfNode::table_row(header_adf_cells));
992
993        // Body rows
994        while !self.at_end() {
995            let line = self.current_line();
996            if !line.contains('|') || line.trim().is_empty() {
997                break;
998            }
999
1000            let cells = parse_table_row(line);
1001            let adf_cells: Vec<AdfNode> = cells
1002                .iter()
1003                .enumerate()
1004                .map(|(col_idx, cell)| {
1005                    let (cell_text, cell_attrs) = extract_cell_attrs(cell);
1006                    let mut para = AdfNode::paragraph(parse_inline(&cell_text));
1007                    apply_column_alignment(&mut para, alignments.get(col_idx).copied().flatten());
1008                    if let Some(attrs) = cell_attrs {
1009                        AdfNode::table_cell_with_attrs(vec![para], attrs)
1010                    } else {
1011                        AdfNode::table_cell(vec![para])
1012                    }
1013                })
1014                .collect();
1015            if adf_cells.is_empty() {
1016                warn!("Pipe table body row at line {} has no cells", self.pos);
1017            }
1018            rows.push(AdfNode::table_row(adf_cells));
1019            self.advance();
1020        }
1021
1022        debug!("Parsed pipe table with {} rows", rows.len());
1023        let mut table = AdfNode::table(rows);
1024
1025        // Check for trailing {attrs} on the next line
1026        if !self.at_end() {
1027            let next = self.current_line().trim();
1028            if next.starts_with('{') {
1029                if let Some((_, attrs)) = parse_attrs(next, 0) {
1030                    let mut table_attrs = serde_json::json!({});
1031                    if let Some(layout) = attrs.get("layout") {
1032                        table_attrs["layout"] = serde_json::Value::String(layout.to_string());
1033                    }
1034                    if attrs.has_flag("numbered") {
1035                        table_attrs["isNumberColumnEnabled"] = serde_json::json!(true);
1036                    } else if attrs.get("numbered") == Some("false") {
1037                        table_attrs["isNumberColumnEnabled"] = serde_json::json!(false);
1038                    }
1039                    if let Some(tw) = attrs.get("width") {
1040                        if let Ok(w) = tw.parse::<f64>() {
1041                            table_attrs["width"] = serde_json::json!(w);
1042                        }
1043                    }
1044                    if let Some(local_id) = attrs.get("localId") {
1045                        table_attrs["localId"] = serde_json::Value::String(local_id.to_string());
1046                    }
1047                    if table_attrs != serde_json::json!({}) {
1048                        table.attrs = Some(table_attrs);
1049                        self.advance(); // consume the attrs line
1050                    }
1051                }
1052            }
1053        }
1054
1055        Ok(Some(table))
1056    }
1057
1058    fn parse_paragraph(&mut self) -> Result<AdfNode> {
1059        let mut lines: Vec<&str> = Vec::new();
1060
1061        while !self.at_end() {
1062            let line = self.current_line();
1063            // Only break on block-level patterns if we already have paragraph
1064            // content. This prevents infinite loops when a line looks like a
1065            // block starter but doesn't actually match any block parser (e.g.,
1066            // "#NoSpace" which is not a valid heading).
1067            // Issue #494: A whitespace-only line that follows a hardBreak
1068            // marker (trailing backslash or two trailing spaces) is a
1069            // continuation, not a paragraph break.  Let it fall through to
1070            // the `is_hardbreak_cont` check below.
1071            if (line.trim().is_empty()
1072                && !lines
1073                    .last()
1074                    .is_some_and(|prev| has_trailing_hard_break(prev)))
1075                || line.starts_with("```")
1076                || (is_horizontal_rule(line) && !lines.is_empty())
1077            {
1078                break;
1079            }
1080            // Strip 2-space indent from hardBreak continuation lines so
1081            // the content round-trips correctly (issue #455).
1082            let is_hardbreak_cont = !lines.is_empty()
1083                && line.starts_with("  ")
1084                && lines
1085                    .last()
1086                    .is_some_and(|prev| has_trailing_hard_break(prev));
1087            if is_hardbreak_cont {
1088                lines.push(&line[2..]);
1089                self.advance();
1090                continue;
1091            }
1092            if !lines.is_empty()
1093                && (line.starts_with('#') || line.starts_with('>') || is_list_start(line))
1094            {
1095                break;
1096            }
1097            // Break on trailing block attrs like {align=center}
1098            if !lines.is_empty() && is_block_attrs_line(line) {
1099                break;
1100            }
1101            lines.push(line);
1102            self.advance();
1103        }
1104
1105        let text = lines.join("\n");
1106        let inline_nodes = parse_inline(&text);
1107        Ok(AdfNode::paragraph(inline_nodes))
1108    }
1109}
1110
1111/// Builds ADF cell attributes from JFM directive attrs.
1112/// Maps: `bg` → `background`, `colspan` → number, `rowspan` → number, `colwidth` → array.
1113fn build_cell_attrs(attrs: &crate::atlassian::attrs::Attrs) -> serde_json::Value {
1114    let mut adf = serde_json::json!({});
1115    if let Some(bg) = attrs.get("bg") {
1116        adf["background"] = serde_json::Value::String(bg.to_string());
1117    }
1118    if let Some(colspan) = attrs.get("colspan") {
1119        if let Ok(n) = colspan.parse::<u32>() {
1120            adf["colspan"] = serde_json::json!(n);
1121        }
1122    }
1123    if let Some(rowspan) = attrs.get("rowspan") {
1124        if let Ok(n) = rowspan.parse::<u32>() {
1125            adf["rowspan"] = serde_json::json!(n);
1126        }
1127    }
1128    if let Some(colwidth) = attrs.get("colwidth") {
1129        let widths: Vec<serde_json::Value> = colwidth
1130            .split(',')
1131            .filter_map(|s| {
1132                let s = s.trim();
1133                // Preserve the original number type: values without a decimal point
1134                // are emitted as integers, values with a decimal point as floats.
1135                if s.contains('.') {
1136                    s.parse::<f64>().ok().map(|n| serde_json::json!(n))
1137                } else {
1138                    s.parse::<u64>().ok().map(|n| serde_json::json!(n))
1139                }
1140            })
1141            .collect();
1142        if !widths.is_empty() {
1143            adf["colwidth"] = serde_json::Value::Array(widths);
1144        }
1145    }
1146    if let Some(local_id) = attrs.get("localId") {
1147        adf["localId"] = serde_json::Value::String(local_id.to_string());
1148    }
1149    adf
1150}
1151
1152/// Extracts border marks from directive attributes (used by table cells and media nodes).
1153fn build_border_marks(attrs: &crate::atlassian::attrs::Attrs) -> Vec<AdfMark> {
1154    let mut marks = Vec::new();
1155    let border_color = attrs.get("border-color");
1156    let border_size = attrs.get("border-size");
1157    if border_color.is_some() || border_size.is_some() {
1158        let color = border_color.unwrap_or("#000000");
1159        let size = border_size.and_then(|s| s.parse::<u32>().ok()).unwrap_or(1);
1160        marks.push(AdfMark::border(color, size));
1161    }
1162    marks
1163}
1164
1165/// Converts an ISO 8601 date string (e.g., "2026-04-15") to epoch milliseconds string.
1166/// If the input is already numeric (epoch ms), returns it unchanged.
1167fn iso_date_to_epoch_ms(date_str: &str) -> String {
1168    // If it's already a numeric timestamp, pass through
1169    if date_str.chars().all(|c| c.is_ascii_digit()) {
1170        return date_str.to_string();
1171    }
1172    if let Ok(date) = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
1173        let epoch_ms = date
1174            .and_hms_opt(0, 0, 0)
1175            .map_or(0, |dt| dt.and_utc().timestamp_millis());
1176        epoch_ms.to_string()
1177    } else {
1178        // Fallback: pass through as-is
1179        date_str.to_string()
1180    }
1181}
1182
1183/// Converts an epoch milliseconds string to an ISO 8601 date string.
1184/// If the input looks like an ISO date already, returns it unchanged.
1185fn epoch_ms_to_iso_date(timestamp: &str) -> String {
1186    // If it looks like an ISO date already, pass through
1187    if timestamp.contains('-') {
1188        return timestamp.to_string();
1189    }
1190    if let Ok(ms) = timestamp.parse::<i64>() {
1191        let secs = ms / 1000;
1192        if let Some(dt) = chrono::DateTime::from_timestamp(secs, 0) {
1193            return dt.format("%Y-%m-%d").to_string();
1194        }
1195    }
1196    // Fallback: pass through
1197    timestamp.to_string()
1198}
1199
1200/// Checks if a line is a standalone block-level attrs line like `{align=center}`.
1201fn is_block_attrs_line(line: &str) -> bool {
1202    let trimmed = line.trim();
1203    if !trimmed.starts_with('{') || !trimmed.ends_with('}') {
1204        return false;
1205    }
1206    if let Some((_, attrs)) = parse_attrs(trimmed, 0) {
1207        // Only consider it a block attrs line if it has recognized block attrs
1208        attrs.get("align").is_some()
1209            || attrs.get("indent").is_some()
1210            || attrs.get("breakout").is_some()
1211            || attrs.get("breakoutWidth").is_some()
1212            || attrs.get("localId").is_some()
1213    } else {
1214        false
1215    }
1216}
1217
1218/// Parses decision items from the inner content of a `:::decisions` container.
1219/// Each item starts with `- <> ` prefix.
1220fn parse_decision_items(text: &str) -> Vec<AdfNode> {
1221    let mut items = Vec::new();
1222    for line in text.lines() {
1223        let trimmed = line.trim();
1224        if let Some(rest) = trimmed.strip_prefix("- <> ") {
1225            let inline_nodes = parse_inline(rest);
1226            items.push(AdfNode::decision_item(
1227                "DECIDED",
1228                vec![AdfNode::paragraph(inline_nodes)],
1229            ));
1230        }
1231    }
1232    items
1233}
1234
1235/// Tries to parse a task list marker `[ ] ` or `[x] ` at the start of text.
1236/// Returns `("TODO"|"DONE", remaining_text)` on success.
1237fn try_parse_task_marker(text: &str) -> Option<(&str, &str)> {
1238    if let Some(rest) = text.strip_prefix("[ ] ") {
1239        Some(("TODO", rest))
1240    } else if let Some(rest) = text
1241        .strip_prefix("[x] ")
1242        .or_else(|| text.strip_prefix("[X] "))
1243    {
1244        Some(("DONE", rest))
1245    } else {
1246        None
1247    }
1248}
1249
1250/// Parses an ordered list marker like "1. " and returns (number, rest_of_line).
1251fn parse_ordered_list_marker(line: &str) -> Option<(u32, &str)> {
1252    let digit_end = line.find(|c: char| !c.is_ascii_digit())?;
1253    if digit_end == 0 {
1254        return None;
1255    }
1256    let rest = &line[digit_end..];
1257    let after_marker = rest.strip_prefix(". ")?;
1258    let num: u32 = line[..digit_end].parse().ok()?;
1259    Some((num, after_marker))
1260}
1261
1262/// Returns true if a line ends with a hardBreak marker
1263/// (trailing backslash or two trailing spaces).
1264fn has_trailing_hard_break(line: &str) -> bool {
1265    line.ends_with('\\') || line.ends_with("  ")
1266}
1267
1268/// Checks if a line starts a list item.
1269fn is_list_start(line: &str) -> bool {
1270    let trimmed = line.trim_start();
1271    trimmed.starts_with("- ")
1272        || trimmed.starts_with("* ")
1273        || trimmed.starts_with("+ ")
1274        || parse_ordered_list_marker(trimmed).is_some()
1275}
1276
1277/// Escapes asterisk sequences in text that would otherwise be parsed as
1278/// CommonMark emphasis (`*…*`) or strong emphasis (`**…**`).
1279///
1280/// Only sequences that could round-trip as emphasis are escaped: a `*` or
1281/// `**` that is followed (at the opening position) or preceded (at the
1282/// closing position) by a non-space character.  Lone asterisks that cannot
1283/// form a delimiter pair are left untouched.
1284fn escape_emphasis_markers(text: &str) -> String {
1285    let mut out = String::with_capacity(text.len());
1286    for ch in text.chars() {
1287        if ch == '*' {
1288            out.push('\\');
1289        }
1290        out.push(ch);
1291    }
1292    out
1293}
1294
1295/// Escapes backtick characters in text that would otherwise be parsed as
1296/// inline code spans (`` `…` ``).
1297///
1298/// Each backtick is prefixed with a backslash so that the JFM parser treats
1299/// it as a literal character rather than an inline-code delimiter.
1300fn escape_backticks(text: &str) -> String {
1301    let mut out = String::with_capacity(text.len());
1302    for ch in text.chars() {
1303        if ch == '`' {
1304            out.push('\\');
1305        }
1306        out.push(ch);
1307    }
1308    out
1309}
1310
1311/// Escapes square brackets (`[` and `]`) in text that will appear inside a
1312/// markdown link's `[…]` delimiters.  Without this, a text node containing a
1313/// literal `[` or `]` can create ambiguous markdown link syntax on round-trip
1314/// (see issue #493).
1315fn escape_link_brackets(text: &str) -> String {
1316    let mut out = String::with_capacity(text.len());
1317    for ch in text.chars() {
1318        if ch == '[' || ch == ']' {
1319            out.push('\\');
1320        }
1321        out.push(ch);
1322    }
1323    out
1324}
1325
1326/// Escapes bare URLs (`http://` and `https://`) in plain text so they are not
1327/// parsed as `inlineCard` nodes during round-trip.  The leading `h` is
1328/// backslash-escaped, which is enough to prevent the auto-link detector from
1329/// matching the URL while the existing backslash-escape handler restores it on
1330/// re-parse.
1331fn escape_bare_urls(text: &str) -> String {
1332    let mut result = String::with_capacity(text.len());
1333    for (i, ch) in text.char_indices() {
1334        if ch == 'h' {
1335            let rest = &text[i..];
1336            if rest.starts_with("http://") || rest.starts_with("https://") {
1337                result.push('\\');
1338            }
1339        }
1340        result.push(ch);
1341    }
1342    result
1343}
1344
1345/// Escapes emoji shortcode patterns (`:name:`) in plain text so they are not
1346/// parsed as emoji nodes during round-trip.  Only the leading colon is
1347/// backslash-escaped, which is enough to prevent the parser from matching the
1348/// pattern while the existing backslash-escape handler restores it on re-parse.
1349fn escape_emoji_shortcodes(text: &str) -> String {
1350    let mut result = String::with_capacity(text.len());
1351
1352    for (i, ch) in text.char_indices() {
1353        if ch == ':' {
1354            // Check if this is a `:name:` pattern where name matches [a-zA-Z0-9_+-]+
1355            let after = i + 1;
1356            if after < text.len() {
1357                let name_end = text[after..]
1358                    .find(|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != '+' && c != '-')
1359                    .map_or(text[after..].len(), |pos| pos);
1360                if name_end > 0
1361                    && after + name_end < text.len()
1362                    && text.as_bytes()[after + name_end] == b':'
1363                {
1364                    // Found `:name:` pattern — escape the leading colon
1365                    result.push('\\');
1366                }
1367            }
1368        }
1369        result.push(ch);
1370    }
1371
1372    result
1373}
1374
1375/// Escapes a leading list-marker pattern on a line so it is not
1376/// re-parsed as a new list item.  `"2. text"` → `"2\. text"`,
1377/// `"- text"` → `"\- text"`.
1378fn escape_list_marker(line: &str) -> String {
1379    if let Some(dot_pos) = line.find(". ") {
1380        if parse_ordered_list_marker(line).is_some() {
1381            let mut s = String::with_capacity(line.len() + 1);
1382            s.push_str(&line[..dot_pos]);
1383            s.push('\\');
1384            s.push_str(&line[dot_pos..]);
1385            return s;
1386        }
1387    }
1388    for prefix in &["- ", "* ", "+ "] {
1389        if line.starts_with(prefix) {
1390            let mut s = String::with_capacity(line.len() + 1);
1391            s.push('\\');
1392            s.push_str(line);
1393            return s;
1394        }
1395    }
1396    line.to_string()
1397}
1398
1399/// Checks if a line is a horizontal rule.
1400fn is_horizontal_rule(line: &str) -> bool {
1401    let trimmed = line.trim();
1402    trimmed.len() >= 3
1403        && ((trimmed.starts_with("---") && trimmed.chars().all(|c| c == '-'))
1404            || (trimmed.starts_with("***") && trimmed.chars().all(|c| c == '*'))
1405            || (trimmed.starts_with("___") && trimmed.chars().all(|c| c == '_')))
1406}
1407
1408/// Checks if a line is a GFM table separator (e.g., "|---|---|").
1409fn is_table_separator(line: &str) -> bool {
1410    let trimmed = line.trim();
1411    trimmed.contains('|')
1412        && trimmed
1413            .chars()
1414            .all(|c| c == '|' || c == '-' || c == ':' || c == ' ')
1415}
1416
1417/// Parses a GFM table row into cell contents.
1418fn parse_table_row(line: &str) -> Vec<String> {
1419    let trimmed = line.trim();
1420    let trimmed = trimmed.strip_prefix('|').unwrap_or(trimmed);
1421    let trimmed = trimmed.strip_suffix('|').unwrap_or(trimmed);
1422
1423    trimmed
1424        .split('|')
1425        .map(|s| {
1426            // Strip exactly one leading and one trailing space (pipe table padding).
1427            // Preserve any additional whitespace as significant content.
1428            let s = s.strip_prefix(' ').unwrap_or(s);
1429            let s = s.strip_suffix(' ').unwrap_or(s);
1430            s.to_string()
1431        })
1432        .collect()
1433}
1434
1435/// Parses column alignments from a GFM table separator row.
1436/// Returns a vec of `Option<&str>` where `Some("center")` or `Some("end")` indicate alignment.
1437fn parse_table_alignments(separator_line: &str) -> Vec<Option<&'static str>> {
1438    let trimmed = separator_line.trim();
1439    let trimmed = trimmed.strip_prefix('|').unwrap_or(trimmed);
1440    let trimmed = trimmed.strip_suffix('|').unwrap_or(trimmed);
1441
1442    trimmed
1443        .split('|')
1444        .map(|cell| {
1445            let cell = cell.trim();
1446            let starts_colon = cell.starts_with(':');
1447            let ends_colon = cell.ends_with(':');
1448            match (starts_colon, ends_colon) {
1449                (true, true) => Some("center"),
1450                (false, true) => Some("end"),
1451                _ => None, // left/default
1452            }
1453        })
1454        .collect()
1455}
1456
1457/// Applies an alignment mark to a paragraph node if alignment is specified.
1458fn apply_column_alignment(para: &mut AdfNode, alignment: Option<&str>) {
1459    if let Some(align) = alignment {
1460        para.marks = Some(vec![AdfMark::alignment(align)]);
1461    }
1462}
1463
1464/// Extracts `{attrs}` prefix from a pipe table cell text.
1465/// Returns `(remaining_text, Option<adf_attrs_json>)`.
1466fn extract_cell_attrs(cell_text: &str) -> (String, Option<serde_json::Value>) {
1467    let trimmed = cell_text.trim_start();
1468    if !trimmed.starts_with('{') {
1469        return (cell_text.to_string(), None);
1470    }
1471    if let Some((end_pos, attrs)) = parse_attrs(trimmed, 0) {
1472        let remaining = trimmed[end_pos..].trim_start().to_string();
1473        let adf_attrs = build_cell_attrs(&attrs);
1474        (remaining, Some(adf_attrs))
1475    } else {
1476        (cell_text.to_string(), None)
1477    }
1478}
1479
1480/// Tries to parse a line as a block-level image and return a mediaSingle ADF node.
1481/// Used by both `try_image` (top-level blocks) and list item parsing.
1482fn try_parse_media_single_from_line(line: &str) -> Option<AdfNode> {
1483    let line = line.trim();
1484    if !line.starts_with("![") {
1485        return None;
1486    }
1487
1488    let (alt, url) = parse_image_syntax(line)?;
1489    let alt_opt = if alt.is_empty() { None } else { Some(alt) };
1490
1491    let paren_open = line.find("](")? + 1; // index of '('
1492    let img_end = find_closing_paren(line, paren_open)? + 1;
1493    let after_img = line[img_end..].trim_start();
1494
1495    if after_img.starts_with('{') {
1496        if let Some((_, attrs)) = parse_attrs(after_img, 0) {
1497            // Confluence file attachment — reconstruct type:file media node
1498            if attrs.get("type") == Some("file") || attrs.get("id").is_some() {
1499                let mut media_attrs = serde_json::json!({"type": "file"});
1500                if let Some(id) = attrs.get("id") {
1501                    media_attrs["id"] = serde_json::Value::String(id.to_string());
1502                }
1503                if let Some(collection) = attrs.get("collection") {
1504                    media_attrs["collection"] = serde_json::Value::String(collection.to_string());
1505                }
1506                if let Some(height) = attrs.get("height") {
1507                    if let Ok(h) = height.parse::<u64>() {
1508                        media_attrs["height"] = serde_json::json!(h);
1509                    }
1510                }
1511                if let Some(width) = attrs.get("width") {
1512                    if let Ok(w) = width.parse::<u64>() {
1513                        media_attrs["width"] = serde_json::json!(w);
1514                    }
1515                }
1516                if let Some(alt_text) = alt_opt {
1517                    media_attrs["alt"] = serde_json::Value::String(alt_text.to_string());
1518                }
1519                if let Some(local_id) = attrs.get("localId") {
1520                    media_attrs["localId"] = serde_json::Value::String(local_id.to_string());
1521                }
1522                let mut ms_attrs = serde_json::json!({"layout": "center"});
1523                if let Some(layout) = attrs.get("layout") {
1524                    ms_attrs["layout"] = serde_json::Value::String(layout.to_string());
1525                }
1526                if let Some(ms_width) = attrs.get("mediaWidth") {
1527                    if let Ok(w) = ms_width.parse::<u64>() {
1528                        ms_attrs["width"] = serde_json::json!(w);
1529                    }
1530                }
1531                if let Some(wt) = attrs.get("widthType") {
1532                    ms_attrs["widthType"] = serde_json::Value::String(wt.to_string());
1533                }
1534                if let Some(mode) = attrs.get("mode") {
1535                    ms_attrs["mode"] = serde_json::Value::String(mode.to_string());
1536                }
1537                let border_marks = build_border_marks(&attrs);
1538                let media_marks = if border_marks.is_empty() {
1539                    None
1540                } else {
1541                    Some(border_marks)
1542                };
1543                return Some(AdfNode {
1544                    node_type: "mediaSingle".to_string(),
1545                    attrs: Some(ms_attrs),
1546                    content: Some(vec![AdfNode {
1547                        node_type: "media".to_string(),
1548                        attrs: Some(media_attrs),
1549                        content: None,
1550                        text: None,
1551                        marks: media_marks,
1552                        local_id: None,
1553                        parameters: None,
1554                    }]),
1555                    text: None,
1556                    marks: None,
1557                    local_id: None,
1558                    parameters: None,
1559                });
1560            }
1561
1562            // External image — apply layout/width/widthType to mediaSingle attrs
1563            let mut node = AdfNode::media_single(url, alt_opt);
1564            if let Some(ref mut node_attrs) = node.attrs {
1565                if let Some(layout) = attrs.get("layout") {
1566                    node_attrs["layout"] = serde_json::Value::String(layout.to_string());
1567                }
1568                if let Some(width) = attrs.get("width") {
1569                    if let Ok(w) = width.parse::<u64>() {
1570                        node_attrs["width"] = serde_json::json!(w);
1571                    }
1572                }
1573                if let Some(wt) = attrs.get("widthType") {
1574                    node_attrs["widthType"] = serde_json::Value::String(wt.to_string());
1575                }
1576                if let Some(mode) = attrs.get("mode") {
1577                    node_attrs["mode"] = serde_json::Value::String(mode.to_string());
1578                }
1579            }
1580            if let Some(ref mut content) = node.content {
1581                if let Some(media) = content.first_mut() {
1582                    if let Some(local_id) = attrs.get("localId") {
1583                        if let Some(ref mut media_attrs) = media.attrs {
1584                            media_attrs["localId"] =
1585                                serde_json::Value::String(local_id.to_string());
1586                        }
1587                    }
1588                    let border_marks = build_border_marks(&attrs);
1589                    if !border_marks.is_empty() {
1590                        media.marks = Some(border_marks);
1591                    }
1592                }
1593            }
1594            return Some(node);
1595        }
1596    }
1597
1598    Some(AdfNode::media_single(url, alt_opt))
1599}
1600
1601/// Parses `![alt](url)` image syntax.
1602fn parse_image_syntax(line: &str) -> Option<(&str, &str)> {
1603    let line = line.trim();
1604    if !line.starts_with("![") {
1605        return None;
1606    }
1607
1608    let alt_end = line.find("](")?;
1609    let alt = &line[2..alt_end];
1610    let paren_start = alt_end + 1; // index of the '('
1611    let url_end = find_closing_paren(line, paren_start)?;
1612    let url = &line[paren_start + 1..url_end];
1613
1614    Some((alt, url))
1615}
1616
1617// ── Inline Parsing ──────────────────────────────────────────────────
1618
1619/// Parses inline markdown content into ADF inline nodes.
1620fn parse_inline(text: &str) -> Vec<AdfNode> {
1621    let mut nodes = Vec::new();
1622    let mut chars = text.char_indices().peekable();
1623    let mut plain_start = 0;
1624
1625    while let Some(&(i, ch)) = chars.peek() {
1626        match ch {
1627            '*' | '_' => {
1628                if let Some((end, content, is_bold)) = try_parse_emphasis(text, i) {
1629                    flush_plain(text, plain_start, i, &mut nodes);
1630                    let mark = if is_bold {
1631                        AdfMark::strong()
1632                    } else {
1633                        AdfMark::em()
1634                    };
1635                    let inner = parse_inline(content);
1636                    for mut node in inner {
1637                        prepend_mark(&mut node, mark.clone());
1638                        nodes.push(node);
1639                    }
1640                    // Advance past the consumed characters
1641                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
1642                        chars.next();
1643                    }
1644                    plain_start = end;
1645                    continue;
1646                }
1647                // For underscores, skip the entire delimiter run so that
1648                // individual `_` chars within a `__` or `___` run are not
1649                // re-tried as separate emphasis openers (CommonMark treats
1650                // consecutive underscores as a single delimiter run).
1651                if ch == '_' {
1652                    while chars.peek().is_some_and(|&(_, c)| c == '_') {
1653                        chars.next();
1654                    }
1655                } else {
1656                    chars.next();
1657                }
1658            }
1659            '~' => {
1660                if let Some((end, content)) = try_parse_strikethrough(text, i) {
1661                    flush_plain(text, plain_start, i, &mut nodes);
1662                    let inner = parse_inline(content);
1663                    for mut node in inner {
1664                        prepend_mark(&mut node, AdfMark::strike());
1665                        nodes.push(node);
1666                    }
1667                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
1668                        chars.next();
1669                    }
1670                    plain_start = end;
1671                    continue;
1672                }
1673                chars.next();
1674            }
1675            '`' => {
1676                if let Some((end, content)) = try_parse_inline_code(text, i) {
1677                    flush_plain(text, plain_start, i, &mut nodes);
1678                    nodes.push(AdfNode::text_with_marks(content, vec![AdfMark::code()]));
1679                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
1680                        chars.next();
1681                    }
1682                    plain_start = end;
1683                    continue;
1684                }
1685                chars.next();
1686            }
1687            '[' => {
1688                if let Some((end, link_text, href)) = try_parse_link(text, i) {
1689                    flush_plain(text, plain_start, i, &mut nodes);
1690                    if link_text.starts_with("http://") || link_text.starts_with("https://") {
1691                        // URL-as-link-text: emit as text with link mark,
1692                        // not via parse_inline which would produce an inlineCard.
1693                        // Covers both exact matches and trailing-slash mismatches
1694                        // (issue #523).
1695                        nodes.push(AdfNode::text_with_marks(
1696                            link_text,
1697                            vec![AdfMark::link(href)],
1698                        ));
1699                    } else {
1700                        let inner = parse_inline(link_text);
1701                        for mut node in inner {
1702                            prepend_mark(&mut node, AdfMark::link(href));
1703                            nodes.push(node);
1704                        }
1705                    }
1706                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
1707                        chars.next();
1708                    }
1709                    plain_start = end;
1710                    continue;
1711                }
1712                // Try bracketed span with attributes: [text]{underline}
1713                if let Some((end, span_nodes)) = try_parse_bracketed_span(text, i) {
1714                    flush_plain(text, plain_start, i, &mut nodes);
1715                    nodes.extend(span_nodes);
1716                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
1717                        chars.next();
1718                    }
1719                    plain_start = end;
1720                    continue;
1721                }
1722                chars.next();
1723            }
1724            ':' => {
1725                // Try generic inline directive (:card[url], :status[text]{attrs}, etc.)
1726                if let Some(node) = try_dispatch_inline_directive(text, i) {
1727                    flush_plain(text, plain_start, i, &mut nodes);
1728                    let end = node.1;
1729                    nodes.push(node.0);
1730                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
1731                        chars.next();
1732                    }
1733                    plain_start = end;
1734                    continue;
1735                }
1736                // Try emoji shortcode :name: with optional {attrs}
1737                if let Some((end, short_name)) = try_parse_emoji_shortcode(text, i) {
1738                    flush_plain(text, plain_start, i, &mut nodes);
1739                    let (final_end, emoji_node) = parse_emoji_with_attrs(text, end, short_name);
1740                    nodes.push(emoji_node);
1741                    while chars.peek().is_some_and(|&(idx, _)| idx < final_end) {
1742                        chars.next();
1743                    }
1744                    plain_start = final_end;
1745                    continue;
1746                }
1747                chars.next();
1748            }
1749            ' ' if text[i..].starts_with("  \n") => {
1750                // Trailing-space line break → hardBreak node.
1751                // Flush preceding text (without the trailing spaces).
1752                flush_plain(text, plain_start, i, &mut nodes);
1753                nodes.push(AdfNode::hard_break());
1754                // Skip past all spaces and the newline
1755                while chars.peek().is_some_and(|&(_, c)| c == ' ') {
1756                    chars.next();
1757                }
1758                // Skip the newline
1759                if chars.peek().is_some_and(|&(_, c)| c == '\n') {
1760                    chars.next();
1761                }
1762                plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
1763            }
1764            '!' if text[i..].starts_with("![") => {
1765                // Inline image — skip the ! and let [ handle it next iteration
1766                // (Images at block level are handled by try_image; inline images
1767                // degrade to link text in ADF since inline media is complex)
1768                chars.next();
1769            }
1770            'h' if text[i..].starts_with("http://") || text[i..].starts_with("https://") => {
1771                if let Some((end, url)) = try_parse_bare_url(text, i) {
1772                    flush_plain(text, plain_start, i, &mut nodes);
1773                    nodes.push(AdfNode::inline_card(url));
1774                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
1775                        chars.next();
1776                    }
1777                    plain_start = end;
1778                    continue;
1779                }
1780                chars.next();
1781            }
1782            '\\' if text.as_bytes().get(i + 1) == Some(&b'n')
1783                && text.as_bytes().get(i + 2) != Some(&b'\n') =>
1784            {
1785                // Issue #454: `\n` (backslash + letter n) encodes a literal
1786                // newline inside a text node. Emit the newline as a separate
1787                // text node so merge_adjacent_text can reassemble it.
1788                flush_plain(text, plain_start, i, &mut nodes);
1789                nodes.push(AdfNode::text("\n"));
1790                chars.next(); // consume the '\'
1791                chars.next(); // consume the 'n'
1792                plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
1793            }
1794            '\\' if i + 1 < text.len() && !text[i..].starts_with("\\\n") => {
1795                // Backslash escape: skip the backslash and treat the next
1796                // character as literal text (e.g. `\\` → `\`,
1797                // `2\. text` → `2. text`, `\*word\*` → `*word*` without
1798                // emphasis, `\:fire:` → `:fire:` without emoji parsing).
1799                flush_plain(text, plain_start, i, &mut nodes);
1800                chars.next(); // consume the backslash
1801                              // Set plain_start to the escaped character so it is included
1802                              // in the next plain-text run, then advance past it so it is
1803                              // not re-interpreted as a special character (e.g. `*`, `_`, `:`).
1804                plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
1805                chars.next(); // consume the escaped character
1806            }
1807            '\\' if text[i..].starts_with("\\\n") => {
1808                // Backslash line break → hardBreak node.
1809                flush_plain(text, plain_start, i, &mut nodes);
1810                nodes.push(AdfNode::hard_break());
1811                chars.next(); // consume the '\'
1812                              // Skip the newline
1813                if chars.peek().is_some_and(|&(_, c)| c == '\n') {
1814                    chars.next();
1815                }
1816                plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
1817            }
1818            '\\' if i + 1 == text.len() => {
1819                // Trailing backslash at end of paragraph text → hardBreak node.
1820                flush_plain(text, plain_start, i, &mut nodes);
1821                nodes.push(AdfNode::hard_break());
1822                chars.next(); // consume the '\'
1823                plain_start = text.len();
1824            }
1825            _ => {
1826                chars.next();
1827            }
1828        }
1829    }
1830
1831    // Flush remaining plain text
1832    if plain_start < text.len() {
1833        let remaining = &text[plain_start..];
1834        if !remaining.is_empty() {
1835            nodes.push(AdfNode::text(remaining));
1836        }
1837    }
1838
1839    // Merge adjacent unmarked text nodes that can arise from backslash
1840    // escape handling (e.g. `"2"` + `". text"` → `"2. text"`).
1841    merge_adjacent_text(&mut nodes);
1842
1843    nodes
1844}
1845
1846/// Merges consecutive unmarked text nodes in-place.
1847fn merge_adjacent_text(nodes: &mut Vec<AdfNode>) {
1848    let mut i = 0;
1849    while i + 1 < nodes.len() {
1850        if nodes[i].node_type == "text"
1851            && nodes[i + 1].node_type == "text"
1852            && nodes[i].marks.is_none()
1853            && nodes[i + 1].marks.is_none()
1854        {
1855            let next_text = nodes[i + 1].text.clone().unwrap_or_default();
1856            if let Some(ref mut t) = nodes[i].text {
1857                t.push_str(&next_text);
1858            }
1859            nodes.remove(i + 1);
1860        } else {
1861            i += 1;
1862        }
1863    }
1864}
1865
1866/// Flushes accumulated plain text as a text node.
1867fn flush_plain(text: &str, start: usize, end: usize, nodes: &mut Vec<AdfNode>) {
1868    if start < end {
1869        let plain = &text[start..end];
1870        if !plain.is_empty() {
1871            nodes.push(AdfNode::text(plain));
1872        }
1873    }
1874}
1875
1876/// Adds a mark to a node (creates marks vec if needed).
1877#[cfg(test)]
1878fn add_mark(node: &mut AdfNode, mark: AdfMark) {
1879    if let Some(ref mut marks) = node.marks {
1880        marks.push(mark);
1881    } else {
1882        node.marks = Some(vec![mark]);
1883    }
1884}
1885
1886/// Prepends a mark before existing marks to preserve outside-in ordering.
1887fn prepend_mark(node: &mut AdfNode, mark: AdfMark) {
1888    if let Some(ref mut marks) = node.marks {
1889        marks.insert(0, mark);
1890    } else {
1891        node.marks = Some(vec![mark]);
1892    }
1893}
1894
1895/// Returns `true` when an underscore delimiter run of `len` bytes starting at
1896/// byte position `delim_pos` in `text` is flanked by alphanumeric characters on
1897/// **both** sides — meaning it sits inside a word and must NOT open or close an
1898/// emphasis span per CommonMark.
1899fn is_intraword_underscore(text: &str, delim_pos: usize, len: usize) -> bool {
1900    let before = text[..delim_pos]
1901        .chars()
1902        .next_back()
1903        .is_some_and(char::is_alphanumeric);
1904    let after = text[delim_pos + len..]
1905        .chars()
1906        .next()
1907        .is_some_and(char::is_alphanumeric);
1908    before && after
1909}
1910
1911/// Finds the first occurrence of `needle` in `haystack`, skipping over
1912/// backslash-escaped characters (e.g. `\*` is not matched when searching
1913/// for `*`).
1914fn find_unescaped(haystack: &str, needle: &str) -> Option<usize> {
1915    let needle_bytes = needle.as_bytes();
1916    let hay_bytes = haystack.as_bytes();
1917    let mut i = 0;
1918    while i < hay_bytes.len() {
1919        if hay_bytes[i] == b'\\' {
1920            i += 2; // skip escaped character
1921            continue;
1922        }
1923        if hay_bytes[i..].starts_with(needle_bytes) {
1924            return Some(i);
1925        }
1926        i += 1;
1927    }
1928    None
1929}
1930
1931/// Finds the first occurrence of a single byte `ch` in `haystack`, skipping
1932/// over backslash-escaped characters.
1933fn find_unescaped_char(haystack: &str, ch: u8) -> Option<usize> {
1934    let hay_bytes = haystack.as_bytes();
1935    let mut i = 0;
1936    while i < hay_bytes.len() {
1937        if hay_bytes[i] == b'\\' {
1938            i += 2;
1939            continue;
1940        }
1941        if hay_bytes[i] == ch {
1942            return Some(i);
1943        }
1944        i += 1;
1945    }
1946    None
1947}
1948
1949/// Tries to parse ***bold+italic***, **bold**, *italic* (or underscore variants) starting at position `i`.
1950/// Returns (end_position, inner_content, is_bold).
1951///
1952/// The triple-delimiter case (`***` / `___`) is checked first so that `***text***` is parsed as
1953/// bold wrapping italic content, rather than having the `**` branch consume the wrong closing
1954/// delimiter and leave stray `*` characters in the text (see issue #401).
1955///
1956/// For underscore delimiters, intraword positions are rejected per CommonMark: a `_` flanked
1957/// by alphanumeric characters on both sides must not open or close emphasis (see issue #438).
1958fn try_parse_emphasis(text: &str, i: usize) -> Option<(usize, &str, bool)> {
1959    let rest = &text[i..];
1960
1961    // Bold+italic: *** or ___
1962    // Parse as bold wrapping italic: the inner content will be recursively parsed and pick up
1963    // the inner * / _ as an em mark.
1964    if rest.starts_with("***") || rest.starts_with("___") {
1965        let is_underscore = rest.starts_with("___");
1966        if is_underscore && is_intraword_underscore(text, i, 3) {
1967            return None;
1968        }
1969        let triple = &rest[..3];
1970        let after = &rest[3..];
1971        if let Some(close) = find_unescaped(after, triple) {
1972            if close > 0 {
1973                let close_pos = i + 3 + close;
1974                if is_underscore && is_intraword_underscore(text, close_pos, 3) {
1975                    return None;
1976                }
1977                // Return a slice that includes the inner italic delimiters from the
1978                // original text: for `***text***`, return `*text*`.  The recursive
1979                // parse_inline call will then pick up the inner `*…*` as an em mark.
1980                let content = &rest[2..=3 + close];
1981                let end = i + 3 + close + 3;
1982                return Some((end, content, true));
1983            }
1984        }
1985    }
1986
1987    // Bold: ** or __
1988    if rest.starts_with("**") || rest.starts_with("__") {
1989        let is_underscore = rest.starts_with("__");
1990        if is_underscore && is_intraword_underscore(text, i, 2) {
1991            return None;
1992        }
1993        let delimiter = &rest[..2];
1994        let after = &rest[2..];
1995        let close = find_unescaped(after, delimiter)?;
1996        if close == 0 {
1997            return None;
1998        }
1999        let close_pos = i + 2 + close;
2000        if is_underscore && is_intraword_underscore(text, close_pos, 2) {
2001            return None;
2002        }
2003        let content = &after[..close];
2004        let end = i + 2 + close + 2;
2005        return Some((end, content, true));
2006    }
2007
2008    // Italic: * or _
2009    if rest.starts_with('*') || rest.starts_with('_') {
2010        let delim_char = rest.as_bytes()[0];
2011        let is_underscore = delim_char == b'_';
2012        if is_underscore && is_intraword_underscore(text, i, 1) {
2013            return None;
2014        }
2015        let after = &rest[1..];
2016        let close = find_unescaped_char(after, delim_char)?;
2017        if close == 0 {
2018            return None;
2019        }
2020        let close_pos = i + 1 + close;
2021        if is_underscore && is_intraword_underscore(text, close_pos, 1) {
2022            return None;
2023        }
2024        let content = &after[..close];
2025        let end = i + 1 + close + 1;
2026        return Some((end, content, false));
2027    }
2028
2029    None
2030}
2031
2032/// Tries to parse ~~strikethrough~~ starting at position `i`.
2033fn try_parse_strikethrough(text: &str, i: usize) -> Option<(usize, &str)> {
2034    let rest = &text[i..];
2035    if !rest.starts_with("~~") {
2036        return None;
2037    }
2038    let after = &rest[2..];
2039    let close = after.find("~~")?;
2040    if close == 0 {
2041        return None;
2042    }
2043    let content = &after[..close];
2044    Some((i + 2 + close + 2, content))
2045}
2046
2047/// Tries to parse `inline code` starting at position `i`.
2048fn try_parse_inline_code(text: &str, i: usize) -> Option<(usize, &str)> {
2049    let rest = &text[i..];
2050    if !rest.starts_with('`') {
2051        return None;
2052    }
2053    let after = &rest[1..];
2054    let close = after.find('`')?;
2055    let content = &after[..close];
2056    Some((i + 1 + close + 1, content))
2057}
2058
2059/// Tries to parse a bracketed span `[text]{attrs}` starting at position `i`.
2060/// Used for `[text]{underline}` and similar constructs.
2061fn try_parse_bracketed_span(text: &str, i: usize) -> Option<(usize, Vec<AdfNode>)> {
2062    let rest = &text[i..];
2063    if !rest.starts_with('[') {
2064        return None;
2065    }
2066
2067    // Find the matching ] by counting bracket depth (supports nested brackets
2068    // such as [[text](url)]{underline} for underline-before-link ordering).
2069    // Backslash-escaped brackets are skipped (issue #493).
2070    let mut depth: usize = 0;
2071    let mut bracket_close = None;
2072    let bs_bytes = rest.as_bytes();
2073    for (j, ch) in rest.char_indices() {
2074        match ch {
2075            '\\' if j + 1 < bs_bytes.len()
2076                && (bs_bytes[j + 1] == b'[' || bs_bytes[j + 1] == b']') => {}
2077            '[' if j == 0 || bs_bytes[j - 1] != b'\\' => depth += 1,
2078            ']' if j == 0 || bs_bytes[j - 1] != b'\\' => {
2079                depth -= 1;
2080                if depth == 0 {
2081                    bracket_close = Some(j);
2082                    break;
2083                }
2084            }
2085            _ => {}
2086        }
2087    }
2088    let bracket_close = bracket_close?;
2089    // Make sure this isn't a link: next char after ] must be { not (
2090    let after_bracket = &rest[bracket_close + 1..];
2091    if !after_bracket.starts_with('{') {
2092        return None;
2093    }
2094
2095    let span_text = &rest[1..bracket_close];
2096    let attrs_start = i + bracket_close + 1;
2097    let (attrs_end, attrs) = parse_attrs(text, attrs_start)?;
2098
2099    let mut marks = Vec::new();
2100    if attrs.has_flag("underline") {
2101        marks.push(AdfMark::underline());
2102    }
2103    let ann_ids = attrs.get_all("annotation-id");
2104    let ann_types = attrs.get_all("annotation-type");
2105    for (idx, ann_id) in ann_ids.iter().enumerate() {
2106        let ann_type = ann_types.get(idx).copied().unwrap_or("inlineComment");
2107        marks.push(AdfMark::annotation(ann_id, ann_type));
2108    }
2109
2110    if marks.is_empty() {
2111        return None; // no recognized marks
2112    }
2113
2114    let inner = parse_inline(span_text);
2115    let result: Vec<AdfNode> = inner
2116        .into_iter()
2117        .map(|mut node| {
2118            // Prepend bracket marks before inner marks to preserve original
2119            // ADF mark ordering (e.g., [underline, strong] not [strong, underline]).
2120            let mut combined = marks.clone();
2121            if let Some(ref existing) = node.marks {
2122                combined.extend(existing.iter().cloned());
2123            }
2124            node.marks = Some(combined);
2125            node
2126        })
2127        .collect();
2128
2129    Some((attrs_end, result))
2130}
2131
2132/// Dispatches an inline directive to the appropriate ADF node constructor.
2133/// Returns `(AdfNode, end_pos)` on success.
2134fn try_dispatch_inline_directive(text: &str, pos: usize) -> Option<(AdfNode, usize)> {
2135    let d = try_parse_inline_directive(text, pos)?;
2136    let content = d.content.as_deref().unwrap_or("");
2137
2138    let node = match d.name.as_str() {
2139        "card" => {
2140            let mut node = AdfNode::inline_card(content);
2141            pass_through_local_id(&d.attrs, &mut node);
2142            node
2143        }
2144        "status" => {
2145            let color = d
2146                .attrs
2147                .as_ref()
2148                .and_then(|a| a.get("color"))
2149                .unwrap_or("neutral");
2150            let mut node = AdfNode::status(content, color);
2151            // Pass through style and localId if present
2152            if let Some(ref attrs) = d.attrs {
2153                if let Some(ref mut node_attrs) = node.attrs {
2154                    if let Some(style) = attrs.get("style") {
2155                        node_attrs["style"] = serde_json::Value::String(style.to_string());
2156                    }
2157                    if let Some(local_id) = attrs.get("localId") {
2158                        node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
2159                    }
2160                }
2161            }
2162            node
2163        }
2164        "date" => {
2165            let timestamp = d
2166                .attrs
2167                .as_ref()
2168                .and_then(|a| a.get("timestamp"))
2169                .map_or_else(|| iso_date_to_epoch_ms(content), ToString::to_string);
2170            let mut node = AdfNode::date(&timestamp);
2171            pass_through_local_id(&d.attrs, &mut node);
2172            node
2173        }
2174        "mention" => {
2175            let id = d.attrs.as_ref().and_then(|a| a.get("id")).unwrap_or("");
2176            let mut node = AdfNode::mention(id, content);
2177            // Pass through optional userType and accessLevel
2178            if let Some(ref attrs) = d.attrs {
2179                if let (Some(ref mut node_attrs), true) = (
2180                    &mut node.attrs,
2181                    attrs.get("userType").is_some() || attrs.get("accessLevel").is_some(),
2182                ) {
2183                    if let Some(ut) = attrs.get("userType") {
2184                        node_attrs["userType"] = serde_json::Value::String(ut.to_string());
2185                    }
2186                    if let Some(al) = attrs.get("accessLevel") {
2187                        node_attrs["accessLevel"] = serde_json::Value::String(al.to_string());
2188                    }
2189                }
2190            }
2191            pass_through_local_id(&d.attrs, &mut node);
2192            node
2193        }
2194        "span" => {
2195            let mut marks = Vec::new();
2196            if let Some(ref attrs) = d.attrs {
2197                if let Some(color) = attrs.get("color") {
2198                    marks.push(AdfMark::text_color(color));
2199                }
2200                if let Some(bg) = attrs.get("bg") {
2201                    marks.push(AdfMark::background_color(bg));
2202                }
2203                if attrs.has_flag("sub") {
2204                    marks.push(AdfMark::subsup("sub"));
2205                }
2206                if attrs.has_flag("sup") {
2207                    marks.push(AdfMark::subsup("sup"));
2208                }
2209            }
2210            if marks.is_empty() {
2211                AdfNode::text(content)
2212            } else {
2213                // Parse inner content to handle nested syntax (e.g., links).
2214                // Prepend span marks before inner marks to preserve ordering.
2215                let inner = parse_inline(content);
2216                let mut nodes: Vec<AdfNode> = inner
2217                    .into_iter()
2218                    .map(|mut node| {
2219                        let mut combined = marks.clone();
2220                        if let Some(ref existing) = node.marks {
2221                            combined.extend(existing.iter().cloned());
2222                        }
2223                        node.marks = Some(combined);
2224                        node
2225                    })
2226                    .collect();
2227                // Return the first marked node (typical case is a single node).
2228                nodes.remove(0)
2229            }
2230        }
2231        "placeholder" => AdfNode::placeholder(content),
2232        "media-inline" => {
2233            let mut json_attrs = serde_json::Map::new();
2234            if let Some(ref attrs) = d.attrs {
2235                for key in &["type", "id", "collection", "url", "alt", "width", "height"] {
2236                    if let Some(val) = attrs.get(key) {
2237                        if *key == "width" || *key == "height" {
2238                            if let Ok(n) = val.parse::<u64>() {
2239                                json_attrs.insert(
2240                                    (*key).to_string(),
2241                                    serde_json::Value::Number(n.into()),
2242                                );
2243                                continue;
2244                            }
2245                        }
2246                        json_attrs.insert(
2247                            (*key).to_string(),
2248                            serde_json::Value::String(val.to_string()),
2249                        );
2250                    }
2251                }
2252                if let Some(local_id) = attrs.get("localId") {
2253                    json_attrs.insert(
2254                        "localId".to_string(),
2255                        serde_json::Value::String(local_id.to_string()),
2256                    );
2257                }
2258            }
2259            AdfNode::media_inline(serde_json::Value::Object(json_attrs))
2260        }
2261        "extension" => {
2262            let ext_type = d.attrs.as_ref().and_then(|a| a.get("type")).unwrap_or("");
2263            let ext_key = d.attrs.as_ref().and_then(|a| a.get("key")).unwrap_or("");
2264            AdfNode::inline_extension(ext_type, ext_key, Some(content))
2265        }
2266        _ => return None, // unknown directive — fall through to plain text
2267    };
2268
2269    Some((node, d.end_pos))
2270}
2271
2272/// Tries to parse a bare URL (`http://` or `https://`) starting at position `i`.
2273/// Scans forward until whitespace, `)`, `]`, or end of string.
2274fn try_parse_bare_url(text: &str, i: usize) -> Option<(usize, &str)> {
2275    let rest = &text[i..];
2276    if !rest.starts_with("http://") && !rest.starts_with("https://") {
2277        return None;
2278    }
2279    // URL extends to the next whitespace or delimiter
2280    let end = rest
2281        .find(|c: char| c.is_whitespace() || c == ')' || c == ']' || c == '>')
2282        .unwrap_or(rest.len());
2283    // Strip trailing punctuation that's likely not part of the URL
2284    let url = rest[..end].trim_end_matches(['.', ',', ';', '!', '?']);
2285    if url.len() <= "https://".len() {
2286        return None; // too short to be a real URL
2287    }
2288    Some((i + url.len(), url))
2289}
2290
2291/// Tries to parse an emoji shortcode `:name:` starting at position `i`.
2292/// The name must match `[a-zA-Z0-9_+-]+`.
2293fn try_parse_emoji_shortcode(text: &str, i: usize) -> Option<(usize, &str)> {
2294    let rest = &text[i..];
2295    if !rest.starts_with(':') {
2296        return None;
2297    }
2298    let after = &rest[1..];
2299    let name_end =
2300        after.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '+' && c != '-')?;
2301    if name_end == 0 {
2302        return None;
2303    }
2304    if after.as_bytes().get(name_end) != Some(&b':') {
2305        return None;
2306    }
2307    let name = &after[..name_end];
2308    Some((i + 1 + name_end + 1, name))
2309}
2310
2311/// Parses an emoji shortcode that has already been matched, then checks for
2312/// trailing `{id="..." text="..."}` attributes to preserve round-trip fidelity.
2313fn parse_emoji_with_attrs(text: &str, shortcode_end: usize, short_name: &str) -> (usize, AdfNode) {
2314    if let Some((attr_end, attrs)) = parse_attrs(text, shortcode_end) {
2315        // Use the explicit shortName attr if provided (preserves original form),
2316        // otherwise fall back to colon-wrapped name.
2317        let resolved_name = attrs
2318            .get("shortName")
2319            .map_or_else(|| format!(":{short_name}:"), str::to_string);
2320        let mut emoji_attrs = serde_json::json!({"shortName": resolved_name});
2321        if let Some(id) = attrs.get("id") {
2322            emoji_attrs["id"] = serde_json::Value::String(id.to_string());
2323        }
2324        if let Some(t) = attrs.get("text") {
2325            emoji_attrs["text"] = serde_json::Value::String(t.to_string());
2326        }
2327        if let Some(lid) = attrs.get("localId") {
2328            emoji_attrs["localId"] = serde_json::Value::String(lid.to_string());
2329        }
2330        (
2331            attr_end,
2332            AdfNode {
2333                node_type: "emoji".to_string(),
2334                attrs: Some(emoji_attrs),
2335                content: None,
2336                text: None,
2337                marks: None,
2338                local_id: None,
2339                parameters: None,
2340            },
2341        )
2342    } else {
2343        (shortcode_end, AdfNode::emoji(&format!(":{short_name}:")))
2344    }
2345}
2346
2347/// Finds the closing `)` that matches the opening `(` at position `open`,
2348/// counting nested parentheses so that URLs containing `(` and `)` are
2349/// handled correctly.  Returns the index of the matching `)` relative to
2350/// the start of `s`, or `None` if no match is found.
2351fn find_closing_paren(s: &str, open: usize) -> Option<usize> {
2352    let mut depth: usize = 0;
2353    for (j, ch) in s[open..].char_indices() {
2354        match ch {
2355            '(' => depth += 1,
2356            ')' => {
2357                depth -= 1;
2358                if depth == 0 {
2359                    return Some(open + j);
2360                }
2361            }
2362            _ => {}
2363        }
2364    }
2365    None
2366}
2367
2368/// Tries to parse [text](url) starting at position `i`.
2369///
2370/// Uses bracket depth counting to find the matching `]`, so that `[` characters
2371/// inside the text (e.g. `[Task] some text ([Link](url))`) don't cause a false
2372/// match on an earlier `](`.
2373fn try_parse_link(text: &str, i: usize) -> Option<(usize, &str, &str)> {
2374    let rest = &text[i..];
2375    if !rest.starts_with('[') {
2376        return None;
2377    }
2378
2379    // Find the matching ] by counting bracket depth, skipping escaped brackets
2380    let mut depth: usize = 0;
2381    let mut text_end = None;
2382    let bytes = rest.as_bytes();
2383    for (j, ch) in rest.char_indices() {
2384        match ch {
2385            '\\' if j + 1 < bytes.len() && (bytes[j + 1] == b'[' || bytes[j + 1] == b']') => {
2386                // Skip backslash-escaped bracket (issue #493)
2387            }
2388            '[' if j == 0 || bytes[j - 1] != b'\\' => depth += 1,
2389            ']' if j == 0 || bytes[j - 1] != b'\\' => {
2390                depth -= 1;
2391                if depth == 0 {
2392                    text_end = Some(j);
2393                    break;
2394                }
2395            }
2396            _ => {}
2397        }
2398    }
2399
2400    let text_end = text_end?;
2401    let link_text = &rest[1..text_end];
2402    // Must be immediately followed by (
2403    let after_bracket = &rest[text_end + 1..];
2404    if !after_bracket.starts_with('(') {
2405        return None;
2406    }
2407    let url_start = text_end + 1; // index of the '('
2408    let url_end = find_closing_paren(rest, url_start)?;
2409    let href = &rest[url_start + 1..url_end];
2410
2411    Some((i + url_end + 1, link_text, href))
2412}
2413
2414// ── ADF → Markdown ──────────────────────────────────────────────────
2415
2416/// Options for ADF-to-markdown rendering.
2417#[derive(Debug, Clone, Default)]
2418pub struct RenderOptions {
2419    /// When true, omit `localId` attributes from directive output.
2420    pub strip_local_ids: bool,
2421}
2422
2423/// Converts an ADF document to a markdown string.
2424pub fn adf_to_markdown(doc: &AdfDocument) -> Result<String> {
2425    adf_to_markdown_with_options(doc, &RenderOptions::default())
2426}
2427
2428/// Converts an ADF document to a markdown string with options.
2429pub fn adf_to_markdown_with_options(doc: &AdfDocument, opts: &RenderOptions) -> Result<String> {
2430    let mut output = String::new();
2431
2432    for (i, node) in doc.content.iter().enumerate() {
2433        if i > 0 {
2434            output.push('\n');
2435        }
2436        render_block_node(node, &mut output, opts);
2437    }
2438
2439    Ok(output)
2440}
2441
2442/// Pushes a `localId=<value>` entry to an attribute parts vec,
2443/// unless `opts.strip_local_ids` is set or the value is a placeholder.
2444/// Copies `localId` from parsed directive attrs to an ADF node's attrs if present.
2445fn pass_through_local_id(dir_attrs: &Option<crate::atlassian::attrs::Attrs>, node: &mut AdfNode) {
2446    if let Some(ref attrs) = dir_attrs {
2447        if let Some(local_id) = attrs.get("localId") {
2448            if let Some(ref mut node_attrs) = node.attrs {
2449                node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
2450            } else {
2451                node.attrs = Some(serde_json::json!({"localId": local_id}));
2452            }
2453        }
2454    }
2455}
2456
2457/// Copies `localId` from directive attrs to the node's top-level `local_id` field,
2458/// and parses `params` JSON from directive attrs into the node's `parameters` field.
2459fn pass_through_expand_params(
2460    dir_attrs: &Option<crate::atlassian::attrs::Attrs>,
2461    node: &mut AdfNode,
2462) {
2463    if let Some(ref attrs) = dir_attrs {
2464        if let Some(local_id) = attrs.get("localId") {
2465            node.local_id = Some(local_id.to_string());
2466        }
2467        if let Some(params_str) = attrs.get("params") {
2468            if let Ok(params) = serde_json::from_str(params_str) {
2469                node.parameters = Some(params);
2470            }
2471        }
2472    }
2473}
2474
2475// listItem localId is emitted as trailing inline attrs on the item line
2476// (e.g., `- item text {localId=...}`) and parsed back by extracting
2477// trailing attrs from the list item text. This avoids the block-attrs
2478// promotion issue where {localId=...} on a separate line would be
2479// applied to the parent list node.
2480
2481/// Extracts trailing `{localId=... paraLocalId=...}` from list item text.
2482/// Returns the text without the trailing attrs, the listItem localId, and
2483/// the paragraph localId if found.
2484fn extract_trailing_local_id(text: &str) -> (&str, Option<String>, Option<String>) {
2485    let trimmed = text.trim_end();
2486    if !trimmed.ends_with('}') {
2487        return (text, None, None);
2488    }
2489    // Find the opening brace.  Only match a standalone `{…}` block that is
2490    // preceded by whitespace (or is at the start of the string).  A `{` that
2491    // immediately follows `]` is part of an inline directive (e.g.
2492    // `:mention[text]{id=… localId=…}`) and must NOT be consumed here.
2493    if let Some(brace_pos) = trimmed.rfind('{') {
2494        if brace_pos > 0 && !trimmed.as_bytes()[brace_pos - 1].is_ascii_whitespace() {
2495            return (text, None, None);
2496        }
2497        let attr_str = &trimmed[brace_pos..];
2498        if let Some((_, attrs)) = parse_attrs(attr_str, 0) {
2499            let local_id = attrs.get("localId").map(str::to_string);
2500            let para_local_id = attrs.get("paraLocalId").map(str::to_string);
2501            if local_id.is_some() || para_local_id.is_some() {
2502                let before = trimmed[..brace_pos]
2503                    .strip_suffix(' ')
2504                    .unwrap_or(&trimmed[..brace_pos]);
2505                return (before, local_id, para_local_id);
2506            }
2507        }
2508    }
2509    (text, None, None)
2510}
2511
2512/// Creates a `listItem` node, optionally with a `localId` attribute
2513/// and a `paraLocalId` on its first paragraph child.
2514/// Parses the first line of a list item and any indented sub-content into
2515/// an `AdfNode::list_item`.  When the first line is a code fence opener
2516/// (`` ``` ``), the line is folded into the sub-content so the block-level
2517/// code fence parser handles it correctly (issue #511).
2518fn parse_list_item_first_line(
2519    item_text: &str,
2520    sub_lines: Vec<String>,
2521    local_id: Option<String>,
2522    para_local_id: Option<String>,
2523) -> Result<AdfNode> {
2524    if item_text.starts_with("```") {
2525        // Treat the code fence opener + indented body as block content.
2526        let mut all_lines = vec![item_text.to_string()];
2527        all_lines.extend(sub_lines);
2528        let combined = all_lines.join("\n");
2529        let nested = MarkdownParser::new(&combined).parse_blocks()?;
2530        Ok(list_item_with_local_id(nested, local_id, para_local_id))
2531    } else if let Some(media) = try_parse_media_single_from_line(item_text) {
2532        // Block-level image (issue #430).
2533        if sub_lines.is_empty() {
2534            Ok(list_item_with_local_id(
2535                vec![media],
2536                local_id,
2537                para_local_id,
2538            ))
2539        } else {
2540            let sub_text = sub_lines.join("\n");
2541            let mut nested = MarkdownParser::new(&sub_text).parse_blocks()?;
2542            let mut content = vec![media];
2543            content.append(&mut nested);
2544            Ok(list_item_with_local_id(content, local_id, para_local_id))
2545        }
2546    } else {
2547        let first_node = AdfNode::paragraph(parse_inline(item_text));
2548        if sub_lines.is_empty() {
2549            Ok(list_item_with_local_id(
2550                vec![first_node],
2551                local_id,
2552                para_local_id,
2553            ))
2554        } else {
2555            let sub_text = sub_lines.join("\n");
2556            let mut nested = MarkdownParser::new(&sub_text).parse_blocks()?;
2557            let mut content = vec![first_node];
2558            content.append(&mut nested);
2559            Ok(list_item_with_local_id(content, local_id, para_local_id))
2560        }
2561    }
2562}
2563
2564fn list_item_with_local_id(
2565    mut content: Vec<AdfNode>,
2566    local_id: Option<String>,
2567    para_local_id: Option<String>,
2568) -> AdfNode {
2569    if let Some(id) = &para_local_id {
2570        if let Some(first) = content.first_mut() {
2571            if first.node_type == "paragraph" {
2572                let node_attrs = first.attrs.get_or_insert_with(|| serde_json::json!({}));
2573                node_attrs["localId"] = serde_json::Value::String(id.clone());
2574            }
2575        }
2576    }
2577    let mut item = AdfNode::list_item(content);
2578    if let Some(id) = local_id {
2579        item.attrs = Some(serde_json::json!({"localId": id}));
2580    }
2581    item
2582}
2583
2584fn maybe_push_local_id(attrs: &serde_json::Value, parts: &mut Vec<String>, opts: &RenderOptions) {
2585    if opts.strip_local_ids {
2586        return;
2587    }
2588    if let Some(local_id) = attrs.get("localId").and_then(serde_json::Value::as_str) {
2589        if !local_id.is_empty() && local_id != "00000000-0000-0000-0000-000000000000" {
2590            parts.push(format!("localId={local_id}"));
2591        }
2592    }
2593}
2594
2595/// Renders a sequence of block nodes with blank-line separators between them.
2596fn render_block_children(children: &[AdfNode], output: &mut String, opts: &RenderOptions) {
2597    for (i, child) in children.iter().enumerate() {
2598        if i > 0 {
2599            output.push('\n');
2600        }
2601        render_block_node(child, output, opts);
2602    }
2603}
2604
2605/// Formats a float as an integer string when it has no fractional part,
2606/// otherwise as a regular float string.
2607fn fmt_f64_attr(v: f64) -> String {
2608    if v.fract() == 0.0 {
2609        format!("{}", v as i64)
2610    } else {
2611        v.to_string()
2612    }
2613}
2614
2615/// Renders a block-level ADF node to markdown.
2616fn render_block_node(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
2617    match node.node_type.as_str() {
2618        "paragraph" => {
2619            let is_empty = node.content.as_ref().map_or(true, Vec::is_empty);
2620            // Build directive attr string for localId when using ::paragraph form
2621            let dir_attrs = {
2622                let mut parts = Vec::new();
2623                if let Some(ref attrs) = node.attrs {
2624                    maybe_push_local_id(attrs, &mut parts, opts);
2625                }
2626                if parts.is_empty() {
2627                    String::new()
2628                } else {
2629                    format!("{{{}}}", parts.join(" "))
2630                }
2631            };
2632            if is_empty {
2633                output.push_str(&format!("::paragraph{dir_attrs}\n"));
2634            } else {
2635                // Render to a buffer first to check if content is whitespace-only
2636                let mut buf = String::new();
2637                render_inline_content(node, &mut buf, opts);
2638                if buf.trim().is_empty() && !buf.is_empty() {
2639                    // Whitespace-only content (e.g. NBSP) would be lost as a plain
2640                    // line — use the ::paragraph[content]{attrs} directive form
2641                    output.push_str(&format!("::paragraph[{buf}]{dir_attrs}\n"));
2642                } else {
2643                    // Escape a leading list-marker pattern so paragraph
2644                    // text is not re-parsed as a list item (issue #402).
2645                    // Indent continuation lines produced by hardBreaks so
2646                    // they are not re-parsed as list items (issue #455).
2647                    let mut is_first_line = true;
2648                    for line in buf.split('\n') {
2649                        if is_first_line {
2650                            if is_list_start(line) {
2651                                output.push_str(&escape_list_marker(line));
2652                            } else {
2653                                output.push_str(line);
2654                            }
2655                            is_first_line = false;
2656                        } else {
2657                            output.push('\n');
2658                            if !line.is_empty() {
2659                                output.push_str("  ");
2660                            }
2661                            output.push_str(line);
2662                        }
2663                    }
2664                    output.push('\n');
2665                }
2666            }
2667        }
2668        "heading" => {
2669            let level = node
2670                .attrs
2671                .as_ref()
2672                .and_then(|a| a.get("level"))
2673                .and_then(serde_json::Value::as_u64)
2674                .unwrap_or(1);
2675            for _ in 0..level {
2676                output.push('#');
2677            }
2678            output.push(' ');
2679            let mut buf = String::new();
2680            render_inline_content(node, &mut buf, opts);
2681            // Indent continuation lines produced by hardBreaks so they stay
2682            // within the heading when re-parsed (issue #433).
2683            let mut is_first_line = true;
2684            for line in buf.split('\n') {
2685                if is_first_line {
2686                    output.push_str(line);
2687                    is_first_line = false;
2688                } else {
2689                    output.push('\n');
2690                    if !line.is_empty() {
2691                        output.push_str("  ");
2692                    }
2693                    output.push_str(line);
2694                }
2695            }
2696            output.push('\n');
2697        }
2698        "codeBlock" => {
2699            let language_value = node.attrs.as_ref().and_then(|a| a.get("language"));
2700            let language = language_value
2701                .and_then(serde_json::Value::as_str)
2702                .unwrap_or("");
2703            output.push_str("```");
2704            if language.is_empty() && language_value.is_some() {
2705                // Explicit empty language attr: encode as ```"" to distinguish
2706                // from a codeBlock with no attrs at all (plain ```).
2707                output.push_str("\"\"");
2708            } else {
2709                output.push_str(language);
2710            }
2711            output.push('\n');
2712            if let Some(ref content) = node.content {
2713                for child in content {
2714                    if let Some(ref text) = child.text {
2715                        output.push_str(text);
2716                    }
2717                }
2718            }
2719            output.push_str("\n```\n");
2720        }
2721        "blockquote" => {
2722            if let Some(ref content) = node.content {
2723                for (i, child) in content.iter().enumerate() {
2724                    // Separate consecutive paragraph siblings with a blank
2725                    // blockquote-prefixed line so they re-parse as distinct
2726                    // paragraphs rather than being merged into one (issue #531).
2727                    if i > 0
2728                        && child.node_type == "paragraph"
2729                        && content[i - 1].node_type == "paragraph"
2730                    {
2731                        output.push_str(">\n");
2732                    }
2733                    let mut inner = String::new();
2734                    render_block_node(child, &mut inner, opts);
2735                    for line in inner.lines() {
2736                        output.push_str("> ");
2737                        output.push_str(line);
2738                        output.push('\n');
2739                    }
2740                }
2741            }
2742        }
2743        "bulletList" => {
2744            if let Some(ref items) = node.content {
2745                for item in items {
2746                    output.push_str("- ");
2747                    render_list_item_content(item, output, opts);
2748                }
2749            }
2750        }
2751        "orderedList" => {
2752            let start = node
2753                .attrs
2754                .as_ref()
2755                .and_then(|a| a.get("order"))
2756                .and_then(serde_json::Value::as_u64)
2757                .unwrap_or(1);
2758            if let Some(ref items) = node.content {
2759                for (i, item) in items.iter().enumerate() {
2760                    let num = start + i as u64;
2761                    output.push_str(&format!("{num}. "));
2762                    render_list_item_content(item, output, opts);
2763                }
2764            }
2765        }
2766        "taskList" => {
2767            if let Some(ref items) = node.content {
2768                for item in items {
2769                    if item.node_type == "taskList" {
2770                        // A nested taskList is a sibling child of the outer
2771                        // taskList — render it indented so it round-trips back
2772                        // as a taskList, not a taskItem (issue #506).
2773                        let mut nested = String::new();
2774                        render_block_node(item, &mut nested, opts);
2775                        for line in nested.lines() {
2776                            output.push_str("  ");
2777                            output.push_str(line);
2778                            output.push('\n');
2779                        }
2780                    } else {
2781                        let state = item
2782                            .attrs
2783                            .as_ref()
2784                            .and_then(|a| a.get("state"))
2785                            .and_then(serde_json::Value::as_str)
2786                            .unwrap_or("TODO");
2787                        if state == "DONE" {
2788                            output.push_str("- [x] ");
2789                        } else {
2790                            output.push_str("- [ ] ");
2791                        }
2792                        render_list_item_content(item, output, opts);
2793                    }
2794                }
2795            }
2796        }
2797        "rule" => {
2798            output.push_str("---\n");
2799        }
2800        "table" => {
2801            render_table(node, output, opts);
2802        }
2803        "mediaSingle" => {
2804            if let Some(ref content) = node.content {
2805                for child in content {
2806                    if child.node_type == "media" {
2807                        render_media(child, node.attrs.as_ref(), output, opts);
2808                    }
2809                }
2810                for child in content {
2811                    if child.node_type == "caption" {
2812                        let mut cap_parts = Vec::new();
2813                        if let Some(ref attrs) = child.attrs {
2814                            maybe_push_local_id(attrs, &mut cap_parts, opts);
2815                        }
2816                        if cap_parts.is_empty() {
2817                            output.push_str(":::caption\n");
2818                        } else {
2819                            output.push_str(&format!(":::caption{{{}}}\n", cap_parts.join(" ")));
2820                        }
2821                        if let Some(ref caption_content) = child.content {
2822                            for inline in caption_content {
2823                                render_inline_node(inline, output, opts);
2824                            }
2825                            output.push('\n');
2826                        }
2827                        output.push_str(":::\n");
2828                    }
2829                }
2830            }
2831        }
2832        "blockCard" => {
2833            if let Some(ref attrs) = node.attrs {
2834                let url = attrs
2835                    .get("url")
2836                    .and_then(serde_json::Value::as_str)
2837                    .unwrap_or("");
2838                output.push_str(&format!("::card[{url}]"));
2839                let mut attr_parts = Vec::new();
2840                if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
2841                    attr_parts.push(format!("layout={layout}"));
2842                }
2843                if let Some(width) = attrs.get("width").and_then(serde_json::Value::as_u64) {
2844                    attr_parts.push(format!("width={width}"));
2845                }
2846                if !attr_parts.is_empty() {
2847                    output.push_str(&format!("{{{}}}", attr_parts.join(" ")));
2848                }
2849                output.push('\n');
2850            }
2851        }
2852        "embedCard" => {
2853            if let Some(ref attrs) = node.attrs {
2854                let url = attrs
2855                    .get("url")
2856                    .and_then(serde_json::Value::as_str)
2857                    .unwrap_or("");
2858                output.push_str(&format!("::embed[{url}]"));
2859                let mut attr_parts = Vec::new();
2860                if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
2861                    attr_parts.push(format!("layout={layout}"));
2862                }
2863                if let Some(h) = attrs
2864                    .get("originalHeight")
2865                    .and_then(serde_json::Value::as_f64)
2866                {
2867                    attr_parts.push(format!("originalHeight={}", fmt_f64_attr(h)));
2868                }
2869                if let Some(w) = attrs.get("width").and_then(serde_json::Value::as_f64) {
2870                    attr_parts.push(format!("width={}", fmt_f64_attr(w)));
2871                }
2872                if !attr_parts.is_empty() {
2873                    output.push_str(&format!("{{{}}}", attr_parts.join(" ")));
2874                }
2875                output.push('\n');
2876            }
2877        }
2878        "extension" => {
2879            if let Some(ref attrs) = node.attrs {
2880                let ext_type = attrs
2881                    .get("extensionType")
2882                    .and_then(serde_json::Value::as_str)
2883                    .unwrap_or("");
2884                let ext_key = attrs
2885                    .get("extensionKey")
2886                    .and_then(serde_json::Value::as_str)
2887                    .unwrap_or("");
2888                let mut attr_parts = vec![format!("type={ext_type}"), format!("key={ext_key}")];
2889                if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
2890                    attr_parts.push(format!("layout={layout}"));
2891                }
2892                if let Some(params) = attrs.get("parameters") {
2893                    if let Ok(json_str) = serde_json::to_string(params) {
2894                        attr_parts.push(format!("params='{json_str}'"));
2895                    }
2896                }
2897                maybe_push_local_id(attrs, &mut attr_parts, opts);
2898                output.push_str(&format!("::extension{{{}}}\n", attr_parts.join(" ")));
2899            }
2900        }
2901        "panel" => {
2902            let panel_type = node
2903                .attrs
2904                .as_ref()
2905                .and_then(|a| a.get("panelType"))
2906                .and_then(serde_json::Value::as_str)
2907                .unwrap_or("info");
2908            let mut attr_parts = vec![format!("type={panel_type}")];
2909            if let Some(ref attrs) = node.attrs {
2910                if let Some(icon) = attrs.get("panelIcon").and_then(serde_json::Value::as_str) {
2911                    attr_parts.push(format!("icon=\"{icon}\""));
2912                }
2913                if let Some(color) = attrs.get("panelColor").and_then(serde_json::Value::as_str) {
2914                    attr_parts.push(format!("color=\"{color}\""));
2915                }
2916            }
2917            output.push_str(&format!(":::panel{{{}}}\n", attr_parts.join(" ")));
2918            if let Some(ref content) = node.content {
2919                render_block_children(content, output, opts);
2920            }
2921            output.push_str(":::\n");
2922        }
2923        "expand" | "nestedExpand" => {
2924            let directive_name = if node.node_type == "nestedExpand" {
2925                "nested-expand"
2926            } else {
2927                "expand"
2928            };
2929            let mut attr_parts = Vec::new();
2930            if let Some(t) = node
2931                .attrs
2932                .as_ref()
2933                .and_then(|a| a.get("title"))
2934                .and_then(serde_json::Value::as_str)
2935            {
2936                attr_parts.push(format!("title=\"{t}\""));
2937            }
2938            // Check top-level localId first, then fall back to attrs.localId
2939            if let Some(ref lid) = node.local_id {
2940                if !opts.strip_local_ids && lid != "00000000-0000-0000-0000-000000000000" {
2941                    attr_parts.push(format!("localId={lid}"));
2942                }
2943            } else if let Some(ref attrs) = node.attrs {
2944                maybe_push_local_id(attrs, &mut attr_parts, opts);
2945            }
2946            // Emit top-level parameters as params='...'
2947            if let Some(ref params) = node.parameters {
2948                if let Ok(json_str) = serde_json::to_string(params) {
2949                    attr_parts.push(format!("params='{json_str}'"));
2950                }
2951            }
2952            if attr_parts.is_empty() {
2953                output.push_str(&format!(":::{directive_name}\n"));
2954            } else {
2955                output.push_str(&format!(
2956                    ":::{directive_name}{{{}}}\n",
2957                    attr_parts.join(" ")
2958                ));
2959            }
2960            if let Some(ref content) = node.content {
2961                render_block_children(content, output, opts);
2962            }
2963            output.push_str(":::\n");
2964        }
2965        "layoutSection" => {
2966            output.push_str("::::layout\n");
2967            if let Some(ref content) = node.content {
2968                for child in content {
2969                    if child.node_type == "layoutColumn" {
2970                        let width = child
2971                            .attrs
2972                            .as_ref()
2973                            .and_then(|a| a.get("width"))
2974                            .and_then(serde_json::Value::as_f64)
2975                            .unwrap_or(50.0);
2976                        let mut parts = vec![format!("width={width}")];
2977                        if let Some(ref attrs) = child.attrs {
2978                            maybe_push_local_id(attrs, &mut parts, opts);
2979                        }
2980                        output.push_str(&format!(":::column{{{}}}\n", parts.join(" ")));
2981                        if let Some(ref col_content) = child.content {
2982                            render_block_children(col_content, output, opts);
2983                        }
2984                        output.push_str(":::\n");
2985                    }
2986                }
2987            }
2988            output.push_str("::::\n");
2989        }
2990        "decisionList" => {
2991            output.push_str(":::decisions\n");
2992            if let Some(ref content) = node.content {
2993                for item in content {
2994                    output.push_str("- <> ");
2995                    render_list_item_content(item, output, opts);
2996                }
2997            }
2998            output.push_str(":::\n");
2999        }
3000        "bodiedExtension" => {
3001            if let Some(ref attrs) = node.attrs {
3002                let ext_type = attrs
3003                    .get("extensionType")
3004                    .and_then(serde_json::Value::as_str)
3005                    .unwrap_or("");
3006                let ext_key = attrs
3007                    .get("extensionKey")
3008                    .and_then(serde_json::Value::as_str)
3009                    .unwrap_or("");
3010                let mut attr_parts = vec![format!("type={ext_type}"), format!("key={ext_key}")];
3011                if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3012                    attr_parts.push(format!("layout={layout}"));
3013                }
3014                if let Some(params) = attrs.get("parameters") {
3015                    if let Ok(json_str) = serde_json::to_string(params) {
3016                        attr_parts.push(format!("params='{json_str}'"));
3017                    }
3018                }
3019                maybe_push_local_id(attrs, &mut attr_parts, opts);
3020                output.push_str(&format!(":::extension{{{}}}\n", attr_parts.join(" ")));
3021                if let Some(ref content) = node.content {
3022                    render_block_children(content, output, opts);
3023                }
3024                output.push_str(":::\n");
3025            }
3026        }
3027        _ => {
3028            // Preserve unsupported nodes as JSON in adf-unsupported code blocks
3029            if let Ok(json) = serde_json::to_string_pretty(node) {
3030                output.push_str("```adf-unsupported\n");
3031                output.push_str(&json);
3032                output.push_str("\n```\n");
3033            }
3034        }
3035    }
3036
3037    // Emit block-level attribute marks (align, indent, breakout) and localId
3038    let mut parts = Vec::new();
3039    if let Some(ref marks) = node.marks {
3040        for mark in marks {
3041            match mark.mark_type.as_str() {
3042                "alignment" => {
3043                    if let Some(align) = mark
3044                        .attrs
3045                        .as_ref()
3046                        .and_then(|a| a.get("align"))
3047                        .and_then(serde_json::Value::as_str)
3048                    {
3049                        parts.push(format!("align={align}"));
3050                    }
3051                }
3052                "indentation" => {
3053                    if let Some(level) = mark
3054                        .attrs
3055                        .as_ref()
3056                        .and_then(|a| a.get("level"))
3057                        .and_then(serde_json::Value::as_u64)
3058                    {
3059                        parts.push(format!("indent={level}"));
3060                    }
3061                }
3062                "breakout" => {
3063                    if let Some(mode) = mark
3064                        .attrs
3065                        .as_ref()
3066                        .and_then(|a| a.get("mode"))
3067                        .and_then(serde_json::Value::as_str)
3068                    {
3069                        parts.push(format!("breakout={mode}"));
3070                    }
3071                    if let Some(width) = mark
3072                        .attrs
3073                        .as_ref()
3074                        .and_then(|a| a.get("width"))
3075                        .and_then(serde_json::Value::as_u64)
3076                    {
3077                        parts.push(format!("breakoutWidth={width}"));
3078                    }
3079                }
3080                _ => {}
3081            }
3082        }
3083    }
3084    // Skip localId for node types that already include it in their directive attrs.
3085    // For paragraphs, localId is included in the ::paragraph directive when the
3086    // paragraph uses directive form (empty or whitespace-only content).
3087    let para_used_directive = node.node_type == "paragraph" && {
3088        let is_empty = node.content.as_ref().map_or(true, Vec::is_empty);
3089        if is_empty {
3090            true
3091        } else {
3092            let mut buf = String::new();
3093            render_inline_content(node, &mut buf, opts);
3094            buf.trim().is_empty() && !buf.is_empty()
3095        }
3096    };
3097    if !matches!(node.node_type.as_str(), "expand" | "nestedExpand") && !para_used_directive {
3098        if let Some(ref attrs) = node.attrs {
3099            maybe_push_local_id(attrs, &mut parts, opts);
3100        }
3101    }
3102    if !parts.is_empty() {
3103        output.push_str(&format!("{{{}}}\n", parts.join(" ")));
3104    }
3105}
3106
3107/// Renders the content of a list item (unwraps the paragraph layer).
3108/// Nested block children (e.g. sub-lists) are indented with two spaces.
3109///
3110/// Some ADF producers (e.g. Confluence) emit `taskItem` content without a
3111/// paragraph wrapper — the inline nodes sit directly inside the item.  We
3112/// detect this by checking whether the first child is an inline node type
3113/// and, if so, render *all* leading inline children on the first line.
3114fn render_list_item_content(item: &AdfNode, output: &mut String, opts: &RenderOptions) {
3115    let Some(ref content) = item.content else {
3116        // Still emit localId and newline for items with no content (e.g. empty taskItem).
3117        let bare = AdfNode::text("");
3118        emit_list_item_local_ids(item, &bare, output, opts);
3119        output.push('\n');
3120        return;
3121    };
3122    if content.is_empty() {
3123        let bare = AdfNode::text("");
3124        emit_list_item_local_ids(item, &bare, output, opts);
3125        output.push('\n');
3126        return;
3127    }
3128    let first = &content[0];
3129    let rest_start;
3130    if first.node_type == "paragraph" {
3131        let mut buf = String::new();
3132        render_inline_content(first, &mut buf, opts);
3133        // A trailing hardBreak produces a trailing `\\\n` in the buffer.
3134        // Strip the final newline so it doesn't create a blank line after
3135        // the list item marker, which would split the list on re-parse
3136        // (issue #472).  The `\` is kept so round-trip preserves the
3137        // hardBreak, and `output.push('\n')` below supplies the terminator.
3138        let buf = buf.trim_end_matches('\n');
3139        // Indent continuation lines produced by hardBreaks so they stay
3140        // within the list item when re-parsed (issue #402).
3141        let mut is_first_line = true;
3142        for line in buf.split('\n') {
3143            if is_first_line {
3144                output.push_str(line);
3145                is_first_line = false;
3146            } else {
3147                output.push('\n');
3148                if !line.is_empty() {
3149                    output.push_str("  ");
3150                }
3151                output.push_str(line);
3152            }
3153        }
3154        // Emit paragraph + listItem localIds as trailing inline attrs on the first line
3155        emit_list_item_local_ids(item, first, output, opts);
3156        output.push('\n');
3157        rest_start = 1;
3158    } else if is_inline_node_type(&first.node_type) {
3159        // Inline nodes without a paragraph wrapper — render them directly.
3160        rest_start = content
3161            .iter()
3162            .position(|c| !is_inline_node_type(&c.node_type))
3163            .unwrap_or(content.len());
3164        let mut buf = String::new();
3165        for child in &content[..rest_start] {
3166            render_inline_node(child, &mut buf, opts);
3167        }
3168        // Indent continuation lines produced by hardBreaks so they stay
3169        // within the list item when re-parsed (issue #521).
3170        let buf = buf.trim_end_matches('\n');
3171        let mut is_first_line = true;
3172        for line in buf.split('\n') {
3173            if is_first_line {
3174                output.push_str(line);
3175                is_first_line = false;
3176            } else {
3177                output.push('\n');
3178                if !line.is_empty() {
3179                    output.push_str("  ");
3180                }
3181                output.push_str(line);
3182            }
3183        }
3184        // No paragraph wrapper — pass a bare node so paraLocalId is omitted.
3185        let bare = AdfNode::text("");
3186        emit_list_item_local_ids(item, &bare, output, opts);
3187        output.push('\n');
3188        // Any remaining children are block nodes — fall through to the
3189        // indented-block loop below.
3190    } else if first.node_type == "taskItem" {
3191        // Malformed ADF: taskItem.content contains nested taskItem nodes
3192        // directly (seen in some Confluence pages).  Render them as an
3193        // indented nested task list to preserve the data without
3194        // corrupting the surrounding structure (issue #489).
3195        let bare = AdfNode::text("");
3196        emit_list_item_local_ids(item, &bare, output, opts);
3197        output.push('\n');
3198        for child in content {
3199            if child.node_type == "taskItem" {
3200                let state = child
3201                    .attrs
3202                    .as_ref()
3203                    .and_then(|a| a.get("state"))
3204                    .and_then(serde_json::Value::as_str)
3205                    .unwrap_or("TODO");
3206                let marker = if state == "DONE" { "- [x] " } else { "- [ ] " };
3207                output.push_str("  ");
3208                output.push_str(marker);
3209                render_list_item_content(child, output, opts);
3210            } else {
3211                let mut nested = String::new();
3212                render_block_node(child, &mut nested, opts);
3213                for line in nested.lines() {
3214                    output.push_str("  ");
3215                    output.push_str(line);
3216                    output.push('\n');
3217                }
3218            }
3219        }
3220        return;
3221    } else {
3222        // Block-level first child (e.g. codeBlock, mediaSingle).
3223        // Render to a buffer so we can:
3224        //  1. Append listItem localId attrs to the first line (issue #525)
3225        //  2. Indent continuation lines so multi-line blocks stay inside
3226        //     the list item (issue #511)
3227        let mut buf = String::new();
3228        render_block_node(first, &mut buf, opts);
3229        let bare = AdfNode::text("");
3230        let mut is_first = true;
3231        for line in buf.lines() {
3232            if is_first {
3233                output.push_str(line);
3234                emit_list_item_local_ids(item, &bare, output, opts);
3235                output.push('\n');
3236                is_first = false;
3237            } else {
3238                output.push_str("  ");
3239                output.push_str(line);
3240                output.push('\n');
3241            }
3242        }
3243        rest_start = 1;
3244    }
3245    let rest = &content[rest_start..];
3246    for (i, child) in rest.iter().enumerate() {
3247        // Separate consecutive paragraph siblings with a blank indented
3248        // line so they re-parse as distinct paragraphs rather than being
3249        // merged into one (issue #522).
3250        if child.node_type == "paragraph" {
3251            let prev_is_para = if i == 0 {
3252                // First rest child — check whether the first-line node
3253                // (rendered above) was a paragraph.
3254                first.node_type == "paragraph"
3255            } else {
3256                rest[i - 1].node_type == "paragraph"
3257            };
3258            if prev_is_para {
3259                output.push_str("  \n");
3260            }
3261        }
3262        let mut nested = String::new();
3263        render_block_node(child, &mut nested, opts);
3264        for line in nested.lines() {
3265            output.push_str("  ");
3266            output.push_str(line);
3267            output.push('\n');
3268        }
3269    }
3270}
3271
3272/// Returns `true` if the given ADF node type is an inline node.
3273fn is_inline_node_type(node_type: &str) -> bool {
3274    matches!(
3275        node_type,
3276        "text"
3277            | "hardBreak"
3278            | "inlineCard"
3279            | "emoji"
3280            | "mention"
3281            | "status"
3282            | "date"
3283            | "placeholder"
3284            | "mediaInline"
3285    )
3286}
3287
3288/// Emits trailing `{localId=... paraLocalId=...}` on a list item line
3289/// for both the listItem and its first (unwrapped) paragraph.
3290fn emit_list_item_local_ids(
3291    item: &AdfNode,
3292    paragraph: &AdfNode,
3293    output: &mut String,
3294    opts: &RenderOptions,
3295) {
3296    if opts.strip_local_ids {
3297        return;
3298    }
3299    let mut parts = Vec::new();
3300    if let Some(ref attrs) = item.attrs {
3301        maybe_push_local_id(attrs, &mut parts, opts);
3302    }
3303    if paragraph.node_type == "paragraph" {
3304        let has_real_id = paragraph
3305            .attrs
3306            .as_ref()
3307            .and_then(|a| a.get("localId"))
3308            .and_then(serde_json::Value::as_str)
3309            .filter(|id| !id.is_empty() && *id != "00000000-0000-0000-0000-000000000000");
3310        if let Some(local_id) = has_real_id {
3311            parts.push(format!("paraLocalId={local_id}"));
3312        } else if item.node_type == "taskItem" {
3313            // taskItem content may or may not have a paragraph wrapper;
3314            // emit a sentinel so the round-trip can distinguish the two
3315            // forms and restore the wrapper (issue #478).
3316            parts.push("paraLocalId=_".to_string());
3317        }
3318    }
3319    if !parts.is_empty() {
3320        output.push_str(&format!(" {{{}}}", parts.join(" ")));
3321    }
3322}
3323
3324/// Renders a table node, choosing between pipe table and directive table form.
3325fn render_table(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
3326    let Some(ref rows) = node.content else {
3327        return;
3328    };
3329
3330    if table_qualifies_for_pipe_syntax(rows) {
3331        render_pipe_table(node, rows, output, opts);
3332    } else {
3333        render_directive_table(node, rows, output, opts);
3334    }
3335}
3336
3337/// Checks whether all cells qualify for GFM pipe table syntax:
3338/// - Every cell has exactly one paragraph child with only inline nodes
3339/// - All `tableHeader` nodes appear exclusively in the first row
3340/// - The first row must contain at least one `tableHeader` (pipe tables
3341///   always treat the first row as headers, so `tableCell`-only first rows
3342///   must use directive form to preserve the cell type)
3343fn table_qualifies_for_pipe_syntax(rows: &[AdfNode]) -> bool {
3344    // Tables with caption nodes must use directive form
3345    if rows.iter().any(|n| n.node_type == "caption") {
3346        return false;
3347    }
3348    let mut first_row_has_header = false;
3349    for (row_idx, row) in rows.iter().enumerate() {
3350        let Some(ref cells) = row.content else {
3351            continue;
3352        };
3353        for cell in cells {
3354            // Header cells outside first row → must use directive form
3355            if row_idx > 0 && cell.node_type == "tableHeader" {
3356                return false;
3357            }
3358            if row_idx == 0 && cell.node_type == "tableHeader" {
3359                first_row_has_header = true;
3360            }
3361            // Check cell has exactly one paragraph with only inline content
3362            let Some(ref content) = cell.content else {
3363                continue;
3364            };
3365            if content.len() != 1 || content[0].node_type != "paragraph" {
3366                return false;
3367            }
3368            // hardBreak inside a cell produces a newline that breaks pipe
3369            // table syntax — fall back to directive form
3370            if cell_contains_hard_break(&content[0]) {
3371                return false;
3372            }
3373            // Cell-level marks (e.g., border) cannot be represented in pipe
3374            // form — fall back to directive form
3375            if cell.marks.as_ref().is_some_and(|m| !m.is_empty()) {
3376                return false;
3377            }
3378            // Paragraph-level localId would be lost in pipe form (the paragraph
3379            // is unwrapped into the cell text) — fall back to directive form
3380            if content[0]
3381                .attrs
3382                .as_ref()
3383                .and_then(|a| a.get("localId"))
3384                .is_some()
3385            {
3386                return false;
3387            }
3388        }
3389    }
3390    // First row must have at least one tableHeader for pipe syntax;
3391    // otherwise the round-trip would convert tableCell → tableHeader.
3392    first_row_has_header
3393}
3394
3395/// Returns true if a paragraph node contains any `hardBreak` inline nodes.
3396fn cell_contains_hard_break(paragraph: &AdfNode) -> bool {
3397    paragraph
3398        .content
3399        .as_ref()
3400        .is_some_and(|nodes| nodes.iter().any(|n| n.node_type == "hardBreak"))
3401}
3402
3403/// Renders a table as GFM pipe syntax.
3404fn render_pipe_table(node: &AdfNode, rows: &[AdfNode], output: &mut String, opts: &RenderOptions) {
3405    for (row_idx, row) in rows.iter().enumerate() {
3406        let Some(ref cells) = row.content else {
3407            continue;
3408        };
3409
3410        output.push('|');
3411        for cell in cells {
3412            output.push(' ');
3413            render_cell_attrs_prefix(cell, output);
3414            render_inline_content_from_first_paragraph(cell, output, opts);
3415            output.push_str(" |");
3416        }
3417        output.push('\n');
3418
3419        // Add separator after header row
3420        if row_idx == 0 {
3421            output.push('|');
3422            for cell in cells {
3423                let align = get_cell_paragraph_alignment(cell);
3424                match align {
3425                    Some("center") => output.push_str(" :---: |"),
3426                    Some("end") => output.push_str(" ---: |"),
3427                    _ => output.push_str(" --- |"),
3428                }
3429            }
3430            output.push('\n');
3431        }
3432    }
3433
3434    // Emit table-level attrs if present
3435    render_table_level_attrs(node, output, opts);
3436}
3437
3438/// Renders a table as `::::table` directive syntax (block-content cells).
3439fn render_directive_table(
3440    node: &AdfNode,
3441    rows: &[AdfNode],
3442    output: &mut String,
3443    opts: &RenderOptions,
3444) {
3445    // Opening fence with attrs
3446    let mut attr_parts = Vec::new();
3447    if let Some(ref attrs) = node.attrs {
3448        if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3449            attr_parts.push(format!("layout={layout}"));
3450        }
3451        if let Some(numbered) = attrs
3452            .get("isNumberColumnEnabled")
3453            .and_then(serde_json::Value::as_bool)
3454        {
3455            if numbered {
3456                attr_parts.push("numbered".to_string());
3457            } else {
3458                attr_parts.push("numbered=false".to_string());
3459            }
3460        }
3461        if let Some(tw) = attrs.get("width").and_then(serde_json::Value::as_f64) {
3462            let tw_str = if tw.fract() == 0.0 {
3463                (tw as u64).to_string()
3464            } else {
3465                tw.to_string()
3466            };
3467            attr_parts.push(format!("width={tw_str}"));
3468        }
3469        maybe_push_local_id(attrs, &mut attr_parts, opts);
3470    }
3471    if attr_parts.is_empty() {
3472        output.push_str("::::table\n");
3473    } else {
3474        output.push_str(&format!("::::table{{{}}}\n", attr_parts.join(" ")));
3475    }
3476
3477    for row in rows {
3478        if row.node_type == "caption" {
3479            let mut cap_parts = Vec::new();
3480            if let Some(ref attrs) = row.attrs {
3481                maybe_push_local_id(attrs, &mut cap_parts, opts);
3482            }
3483            if cap_parts.is_empty() {
3484                output.push_str(":::caption\n");
3485            } else {
3486                output.push_str(&format!(":::caption{{{}}}\n", cap_parts.join(" ")));
3487            }
3488            if let Some(ref content) = row.content {
3489                for child in content {
3490                    render_inline_node(child, output, opts);
3491                }
3492                output.push('\n');
3493            }
3494            output.push_str(":::\n");
3495            continue;
3496        }
3497        let Some(ref cells) = row.content else {
3498            continue;
3499        };
3500        // Emit :::tr with optional localId
3501        let mut tr_attrs = Vec::new();
3502        if let Some(ref attrs) = row.attrs {
3503            maybe_push_local_id(attrs, &mut tr_attrs, opts);
3504        }
3505        if tr_attrs.is_empty() {
3506            output.push_str(":::tr\n");
3507        } else {
3508            output.push_str(&format!(":::tr{{{}}}\n", tr_attrs.join(" ")));
3509        }
3510        for cell in cells {
3511            let directive_name = if cell.node_type == "tableHeader" {
3512                "th"
3513            } else {
3514                "td"
3515            };
3516            let mut cell_attr_str = build_cell_attrs_string(cell);
3517            // Append localId to cell attrs if present
3518            if let Some(ref attrs) = cell.attrs {
3519                let mut lid_parts = Vec::new();
3520                maybe_push_local_id(attrs, &mut lid_parts, opts);
3521                if !lid_parts.is_empty() {
3522                    if !cell_attr_str.is_empty() {
3523                        cell_attr_str.push(' ');
3524                    }
3525                    cell_attr_str.push_str(&lid_parts.join(" "));
3526                }
3527            }
3528            // Append border mark attrs if present
3529            if let Some(ref marks) = cell.marks {
3530                for mark in marks {
3531                    if mark.mark_type == "border" {
3532                        if let Some(ref attrs) = mark.attrs {
3533                            if let Some(color) =
3534                                attrs.get("color").and_then(serde_json::Value::as_str)
3535                            {
3536                                if !cell_attr_str.is_empty() {
3537                                    cell_attr_str.push(' ');
3538                                }
3539                                cell_attr_str.push_str(&format!("border-color={color}"));
3540                            }
3541                            if let Some(size) =
3542                                attrs.get("size").and_then(serde_json::Value::as_u64)
3543                            {
3544                                if !cell_attr_str.is_empty() {
3545                                    cell_attr_str.push(' ');
3546                                }
3547                                cell_attr_str.push_str(&format!("border-size={size}"));
3548                            }
3549                        }
3550                    }
3551                }
3552            }
3553            let has_marks = cell.marks.as_ref().is_some_and(|m| !m.is_empty());
3554            if cell_attr_str.is_empty() && cell.attrs.is_none() && !has_marks {
3555                output.push_str(&format!(":::{directive_name}\n"));
3556            } else {
3557                output.push_str(&format!(":::{directive_name}{{{cell_attr_str}}}\n"));
3558            }
3559            if let Some(ref content) = cell.content {
3560                render_block_children(content, output, opts);
3561            }
3562            output.push_str(":::\n");
3563        }
3564        output.push_str(":::\n");
3565    }
3566
3567    output.push_str("::::\n");
3568}
3569
3570/// Returns `true` when an attribute value must be quoted to survive round-trip
3571/// through the `{key=value}` attribute parser (which stops unquoted values at
3572/// whitespace or `}`).
3573fn needs_attr_quoting(value: &str) -> bool {
3574    value.contains(|c: char| c.is_whitespace() || c == '}' || c == '(' || c == ')' || c == ',')
3575}
3576
3577/// Builds a JFM attribute string from ADF cell attributes.
3578fn build_cell_attrs_string(cell: &AdfNode) -> String {
3579    let Some(ref attrs) = cell.attrs else {
3580        return String::new();
3581    };
3582    let mut parts = Vec::new();
3583    if let Some(colspan) = attrs.get("colspan").and_then(serde_json::Value::as_u64) {
3584        parts.push(format!("colspan={colspan}"));
3585    }
3586    if let Some(rowspan) = attrs.get("rowspan").and_then(serde_json::Value::as_u64) {
3587        parts.push(format!("rowspan={rowspan}"));
3588    }
3589    if let Some(bg) = attrs.get("background").and_then(serde_json::Value::as_str) {
3590        if needs_attr_quoting(bg) {
3591            let escaped = bg.replace('\\', "\\\\").replace('"', "\\\"");
3592            parts.push(format!("bg=\"{escaped}\""));
3593        } else {
3594            parts.push(format!("bg={bg}"));
3595        }
3596    }
3597    if let Some(colwidth) = attrs.get("colwidth").and_then(serde_json::Value::as_array) {
3598        let widths: Vec<String> = colwidth
3599            .iter()
3600            .filter_map(|v| {
3601                // Preserve the original number type: integers stay as integers,
3602                // floats stay as floats (e.g. Confluence's 254.0 representation).
3603                if let Some(n) = v.as_u64() {
3604                    Some(n.to_string())
3605                } else if let Some(n) = v.as_f64() {
3606                    if n.fract() == 0.0 {
3607                        format!("{n:.1}")
3608                    } else {
3609                        n.to_string()
3610                    }
3611                    .into()
3612                } else {
3613                    None
3614                }
3615            })
3616            .collect();
3617        if !widths.is_empty() {
3618            parts.push(format!("colwidth={}", widths.join(",")));
3619        }
3620    }
3621    parts.join(" ")
3622}
3623
3624/// Renders `{attrs}` prefix for a pipe table cell (background, colspan, etc.).
3625fn render_cell_attrs_prefix(cell: &AdfNode, output: &mut String) {
3626    let Some(ref _attrs) = cell.attrs else {
3627        return;
3628    };
3629    let attr_str = build_cell_attrs_string(cell);
3630    if attr_str.is_empty() {
3631        output.push_str("{} ");
3632    } else {
3633        output.push_str(&format!("{{{attr_str}}} "));
3634    }
3635}
3636
3637/// Gets the alignment mark value from the paragraph inside a table cell.
3638fn get_cell_paragraph_alignment(cell: &AdfNode) -> Option<&str> {
3639    let content = cell.content.as_ref()?;
3640    let para = content.first()?;
3641    let marks = para.marks.as_ref()?;
3642    marks.iter().find_map(|m| {
3643        if m.mark_type == "alignment" {
3644            m.attrs
3645                .as_ref()
3646                .and_then(|a| a.get("align"))
3647                .and_then(serde_json::Value::as_str)
3648        } else {
3649            None
3650        }
3651    })
3652}
3653
3654/// Emits table-level attributes if present.
3655fn render_table_level_attrs(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
3656    if let Some(ref attrs) = node.attrs {
3657        let mut parts = Vec::new();
3658        if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3659            parts.push(format!("layout={layout}"));
3660        }
3661        if let Some(numbered) = attrs
3662            .get("isNumberColumnEnabled")
3663            .and_then(serde_json::Value::as_bool)
3664        {
3665            if numbered {
3666                parts.push("numbered".to_string());
3667            } else {
3668                parts.push("numbered=false".to_string());
3669            }
3670        }
3671        if let Some(tw) = attrs.get("width").and_then(serde_json::Value::as_f64) {
3672            let tw_str = if tw.fract() == 0.0 {
3673                (tw as u64).to_string()
3674            } else {
3675                tw.to_string()
3676            };
3677            parts.push(format!("width={tw_str}"));
3678        }
3679        maybe_push_local_id(attrs, &mut parts, opts);
3680        if !parts.is_empty() {
3681            output.push_str(&format!("{{{}}}\n", parts.join(" ")));
3682        }
3683    }
3684}
3685
3686/// Renders inline content from the first paragraph child of a table cell.
3687fn render_inline_content_from_first_paragraph(
3688    cell: &AdfNode,
3689    output: &mut String,
3690    opts: &RenderOptions,
3691) {
3692    if let Some(ref content) = cell.content {
3693        if let Some(first) = content.first() {
3694            if first.node_type == "paragraph" {
3695                render_inline_content(first, output, opts);
3696            }
3697        }
3698    }
3699}
3700
3701/// Appends border mark attributes (border-color, border-size) to a parts vec.
3702fn push_border_mark_attrs(marks: &Option<Vec<AdfMark>>, parts: &mut Vec<String>) {
3703    if let Some(ref marks) = marks {
3704        for mark in marks {
3705            if mark.mark_type == "border" {
3706                if let Some(ref attrs) = mark.attrs {
3707                    if let Some(color) = attrs.get("color").and_then(serde_json::Value::as_str) {
3708                        parts.push(format!("border-color={color}"));
3709                    }
3710                    if let Some(size) = attrs.get("size").and_then(serde_json::Value::as_u64) {
3711                        parts.push(format!("border-size={size}"));
3712                    }
3713                }
3714            }
3715        }
3716    }
3717}
3718
3719/// Renders a media node as a markdown image, with optional parent (mediaSingle) attrs.
3720fn render_media(
3721    node: &AdfNode,
3722    parent_attrs: Option<&serde_json::Value>,
3723    output: &mut String,
3724    opts: &RenderOptions,
3725) {
3726    if let Some(ref attrs) = node.attrs {
3727        let media_type = attrs
3728            .get("type")
3729            .and_then(serde_json::Value::as_str)
3730            .unwrap_or("external");
3731        let alt = attrs
3732            .get("alt")
3733            .and_then(serde_json::Value::as_str)
3734            .unwrap_or("");
3735
3736        if media_type == "file" {
3737            // Confluence file attachment — encode metadata in {attrs} block so it survives round-trip
3738            output.push_str(&format!("![{alt}]()"));
3739            let mut parts = vec!["type=file".to_string()];
3740            if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
3741                parts.push(format!("id={id}"));
3742            }
3743            if let Some(collection) = attrs.get("collection").and_then(serde_json::Value::as_str) {
3744                parts.push(format!("collection={collection}"));
3745            }
3746            if let Some(height) = attrs.get("height").and_then(serde_json::Value::as_u64) {
3747                parts.push(format!("height={height}"));
3748            }
3749            if let Some(width) = attrs.get("width").and_then(serde_json::Value::as_u64) {
3750                parts.push(format!("width={width}"));
3751            }
3752            maybe_push_local_id(attrs, &mut parts, opts);
3753            // Encode mediaSingle layout/width/widthType if non-default
3754            if let Some(p_attrs) = parent_attrs {
3755                if let Some(layout) = p_attrs.get("layout").and_then(serde_json::Value::as_str) {
3756                    if layout != "center" {
3757                        parts.push(format!("layout={layout}"));
3758                    }
3759                }
3760                if let Some(ms_width) = p_attrs.get("width").and_then(serde_json::Value::as_u64) {
3761                    parts.push(format!("mediaWidth={ms_width}"));
3762                }
3763                if let Some(wt) = p_attrs.get("widthType").and_then(serde_json::Value::as_str) {
3764                    parts.push(format!("widthType={wt}"));
3765                }
3766                if let Some(mode) = p_attrs.get("mode").and_then(serde_json::Value::as_str) {
3767                    parts.push(format!("mode={mode}"));
3768                }
3769            }
3770            push_border_mark_attrs(&node.marks, &mut parts);
3771            output.push_str(&format!("{{{}}}", parts.join(" ")));
3772        } else {
3773            // External image
3774            let url = attrs
3775                .get("url")
3776                .and_then(serde_json::Value::as_str)
3777                .unwrap_or("");
3778            output.push_str(&format!("![{alt}]({url})"));
3779
3780            // Emit {layout=... width=... widthType=... mode=... localId=...} if non-default attrs present
3781            {
3782                let mut parts = Vec::new();
3783                if let Some(p_attrs) = parent_attrs {
3784                    let layout = p_attrs.get("layout").and_then(serde_json::Value::as_str);
3785                    let width = p_attrs.get("width").and_then(serde_json::Value::as_u64);
3786                    let width_type = p_attrs.get("widthType").and_then(serde_json::Value::as_str);
3787                    if let Some(l) = layout {
3788                        if l != "center" {
3789                            parts.push(format!("layout={l}"));
3790                        }
3791                    }
3792                    if let Some(w) = width {
3793                        parts.push(format!("width={w}"));
3794                    }
3795                    if let Some(wt) = width_type {
3796                        parts.push(format!("widthType={wt}"));
3797                    }
3798                    if let Some(mode) = p_attrs.get("mode").and_then(serde_json::Value::as_str) {
3799                        parts.push(format!("mode={mode}"));
3800                    }
3801                }
3802                maybe_push_local_id(attrs, &mut parts, opts);
3803                push_border_mark_attrs(&node.marks, &mut parts);
3804                if !parts.is_empty() {
3805                    output.push_str(&format!("{{{}}}", parts.join(" ")));
3806                }
3807            }
3808        }
3809
3810        output.push('\n');
3811    }
3812}
3813
3814/// Renders inline content (text nodes with marks) from a block node's children.
3815fn render_inline_content(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
3816    if let Some(ref content) = node.content {
3817        for child in content {
3818            render_inline_node(child, output, opts);
3819        }
3820    }
3821}
3822
3823/// Renders a single inline ADF node to markdown.
3824fn render_inline_node(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
3825    match node.node_type.as_str() {
3826        "text" => {
3827            let text = node.text.as_deref().unwrap_or("");
3828            let marks = node.marks.as_deref().unwrap_or(&[]);
3829            let has_code = marks.iter().any(|m| m.mark_type == "code");
3830            // Issue #477: Escape literal backslashes before the newline
3831            // encoding below so they are not consumed as JFM escape
3832            // sequences on round-trip.  Code marks emit content verbatim,
3833            // so backslash escaping is skipped for them.
3834            let owned;
3835            let text = if !has_code {
3836                owned = text.replace('\\', "\\\\");
3837                owned.as_str()
3838            } else {
3839                text
3840            };
3841            // Issue #454: A literal newline inside a text node is escaped
3842            // as the two-character sequence `\n` so it survives round-trip
3843            // as a single text node instead of splitting into paragraphs or
3844            // being converted to hardBreak nodes.
3845            let owned_nl;
3846            let text = if text.contains('\n') {
3847                owned_nl = text.replace('\n', "\\n");
3848                owned_nl.as_str()
3849            } else {
3850                text
3851            };
3852            // Issue #510: Two or more trailing spaces at the end of a text
3853            // node would be misinterpreted as a hardBreak marker on
3854            // round-trip (and collapse the following paragraph).  Escape the
3855            // last space with a backslash so the parser treats it as a
3856            // literal space instead of a line-break signal.
3857            let owned_ts;
3858            let text = if !has_code && text.ends_with("  ") {
3859                let mut s = text.to_string();
3860                // Insert backslash before the final space: "foo  " → "foo \ "
3861                s.insert(s.len() - 1, '\\');
3862                owned_ts = s;
3863                owned_ts.as_str()
3864            } else {
3865                text
3866            };
3867            render_marked_text(text, marks, output);
3868        }
3869        "hardBreak" => {
3870            output.push_str("\\\n");
3871        }
3872        other => {
3873            // Issue #471: Non-text inline nodes (emoji, status, date, mention, etc.)
3874            // may carry annotation marks. Render the node body first, then wrap it
3875            // in bracketed-span syntax if annotation marks are present.
3876            let mut body = String::new();
3877            render_non_text_inline_body(other, node, &mut body, opts);
3878
3879            let annotations: Vec<&AdfMark> = node
3880                .marks
3881                .as_deref()
3882                .unwrap_or(&[])
3883                .iter()
3884                .filter(|m| m.mark_type == "annotation")
3885                .collect();
3886
3887            if annotations.is_empty() {
3888                output.push_str(&body);
3889            } else {
3890                let mut attr_parts = Vec::new();
3891                for ann in &annotations {
3892                    if let Some(ref attrs) = ann.attrs {
3893                        if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
3894                            let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
3895                            attr_parts.push(format!("annotation-id=\"{escaped}\""));
3896                        }
3897                        if let Some(at) = attrs
3898                            .get("annotationType")
3899                            .and_then(serde_json::Value::as_str)
3900                        {
3901                            attr_parts.push(format!("annotation-type={at}"));
3902                        }
3903                    }
3904                }
3905                output.push('[');
3906                output.push_str(&body);
3907                output.push_str("]{");
3908                output.push_str(&attr_parts.join(" "));
3909                output.push('}');
3910            }
3911        }
3912    }
3913}
3914
3915/// Renders the body of a non-text inline node (without mark wrapping).
3916fn render_non_text_inline_body(
3917    node_type: &str,
3918    node: &AdfNode,
3919    output: &mut String,
3920    opts: &RenderOptions,
3921) {
3922    match node_type {
3923        "inlineCard" => {
3924            if let Some(ref attrs) = node.attrs {
3925                if let Some(url) = attrs.get("url").and_then(serde_json::Value::as_str) {
3926                    output.push_str(":card[");
3927                    output.push_str(url);
3928                    output.push(']');
3929                    let mut attr_parts = Vec::new();
3930                    maybe_push_local_id(attrs, &mut attr_parts, opts);
3931                    if !attr_parts.is_empty() {
3932                        output.push('{');
3933                        output.push_str(&attr_parts.join(" "));
3934                        output.push('}');
3935                    }
3936                }
3937            }
3938        }
3939        "emoji" => {
3940            if let Some(ref attrs) = node.attrs {
3941                if let Some(short_name) = attrs.get("shortName").and_then(serde_json::Value::as_str)
3942                {
3943                    output.push(':');
3944                    let name = short_name.strip_prefix(':').unwrap_or(short_name);
3945                    let name = name.strip_suffix(':').unwrap_or(name);
3946                    output.push_str(name);
3947                    output.push(':');
3948
3949                    let mut parts = Vec::new();
3950                    let escaped_sn = short_name.replace('\\', "\\\\").replace('"', "\\\"");
3951                    parts.push(format!("shortName=\"{escaped_sn}\""));
3952                    if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
3953                        let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
3954                        parts.push(format!("id=\"{escaped}\""));
3955                    }
3956                    if let Some(text) = attrs.get("text").and_then(serde_json::Value::as_str) {
3957                        let escaped = text.replace('\\', "\\\\").replace('"', "\\\"");
3958                        parts.push(format!("text=\"{escaped}\""));
3959                    }
3960                    maybe_push_local_id(attrs, &mut parts, opts);
3961                    output.push('{');
3962                    output.push_str(&parts.join(" "));
3963                    output.push('}');
3964                }
3965            }
3966        }
3967        "status" => {
3968            if let Some(ref attrs) = node.attrs {
3969                let text = attrs
3970                    .get("text")
3971                    .and_then(serde_json::Value::as_str)
3972                    .unwrap_or("");
3973                let color = attrs
3974                    .get("color")
3975                    .and_then(serde_json::Value::as_str)
3976                    .unwrap_or("neutral");
3977                let mut attr_parts = vec![format!("color={color}")];
3978                if let Some(style) = attrs.get("style").and_then(serde_json::Value::as_str) {
3979                    attr_parts.push(format!("style={style}"));
3980                }
3981                maybe_push_local_id(attrs, &mut attr_parts, opts);
3982                output.push_str(&format!(":status[{text}]{{{}}}", attr_parts.join(" ")));
3983            }
3984        }
3985        "date" => {
3986            if let Some(ref attrs) = node.attrs {
3987                if let Some(timestamp) = attrs.get("timestamp").and_then(serde_json::Value::as_str)
3988                {
3989                    let display = epoch_ms_to_iso_date(timestamp);
3990                    let mut attr_parts = vec![format!("timestamp={timestamp}")];
3991                    maybe_push_local_id(attrs, &mut attr_parts, opts);
3992                    output.push_str(&format!(":date[{display}]{{{}}}", attr_parts.join(" ")));
3993                }
3994            }
3995        }
3996        "mention" => {
3997            if let Some(ref attrs) = node.attrs {
3998                let id = attrs
3999                    .get("id")
4000                    .and_then(serde_json::Value::as_str)
4001                    .unwrap_or("");
4002                let text = attrs
4003                    .get("text")
4004                    .and_then(serde_json::Value::as_str)
4005                    .unwrap_or("");
4006                let mut attr_parts = vec![format!("id={id}")];
4007                if let Some(ut) = attrs.get("userType").and_then(serde_json::Value::as_str) {
4008                    attr_parts.push(format!("userType={ut}"));
4009                }
4010                if let Some(al) = attrs.get("accessLevel").and_then(serde_json::Value::as_str) {
4011                    attr_parts.push(format!("accessLevel={al}"));
4012                }
4013                maybe_push_local_id(attrs, &mut attr_parts, opts);
4014                output.push_str(&format!(":mention[{text}]{{{}}}", attr_parts.join(" ")));
4015            }
4016        }
4017        "placeholder" => {
4018            if let Some(ref attrs) = node.attrs {
4019                let text = attrs
4020                    .get("text")
4021                    .and_then(serde_json::Value::as_str)
4022                    .unwrap_or("");
4023                output.push_str(&format!(":placeholder[{text}]"));
4024            }
4025        }
4026        "inlineExtension" => {
4027            if let Some(ref attrs) = node.attrs {
4028                let ext_type = attrs
4029                    .get("extensionType")
4030                    .and_then(serde_json::Value::as_str)
4031                    .unwrap_or("");
4032                let ext_key = attrs
4033                    .get("extensionKey")
4034                    .and_then(serde_json::Value::as_str)
4035                    .unwrap_or("");
4036                let fallback = node.text.as_deref().unwrap_or("");
4037                output.push_str(&format!(
4038                    ":extension[{fallback}]{{type={ext_type} key={ext_key}}}"
4039                ));
4040            }
4041        }
4042        "mediaInline" => {
4043            if let Some(ref attrs) = node.attrs {
4044                let mut attr_parts = Vec::new();
4045                if let Some(media_type) = attrs.get("type").and_then(serde_json::Value::as_str) {
4046                    attr_parts.push(format!("type={media_type}"));
4047                }
4048                if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4049                    attr_parts.push(format!("id={id}"));
4050                }
4051                if let Some(collection) =
4052                    attrs.get("collection").and_then(serde_json::Value::as_str)
4053                {
4054                    attr_parts.push(format!("collection={collection}"));
4055                }
4056                if let Some(url) = attrs.get("url").and_then(serde_json::Value::as_str) {
4057                    attr_parts.push(format!("url={url}"));
4058                }
4059                if let Some(alt) = attrs.get("alt").and_then(serde_json::Value::as_str) {
4060                    attr_parts.push(format!("alt={alt}"));
4061                }
4062                if let Some(width) = attrs.get("width").and_then(serde_json::Value::as_u64) {
4063                    attr_parts.push(format!("width={width}"));
4064                }
4065                if let Some(height) = attrs.get("height").and_then(serde_json::Value::as_u64) {
4066                    attr_parts.push(format!("height={height}"));
4067                }
4068                maybe_push_local_id(attrs, &mut attr_parts, opts);
4069                output.push_str(&format!(":media-inline[]{{{}}}", attr_parts.join(" ")));
4070            }
4071        }
4072        _ => {
4073            output.push_str(&format!("<!-- unsupported inline: {} -->", node.node_type));
4074        }
4075    }
4076}
4077
4078/// Renders text with ADF marks applied as markdown syntax.
4079///
4080/// Mark ordering is preserved by checking the position of the `link` mark
4081/// relative to formatting marks. Formatting marks that appear before `link`
4082/// in the marks array are rendered as outer wrappers (e.g., `**[text](url)**`),
4083/// while those after `link` are rendered inside the link (e.g., `[**text**](url)`).
4084fn render_marked_text(text: &str, marks: &[AdfMark], output: &mut String) {
4085    let link_pos = marks.iter().position(|m| m.mark_type == "link");
4086    let has_link = link_pos.map(|lp| &marks[lp]);
4087    let has_strong = marks.iter().any(|m| m.mark_type == "strong");
4088    let has_em = marks.iter().any(|m| m.mark_type == "em");
4089    let has_code = marks.iter().any(|m| m.mark_type == "code");
4090    let has_strike = marks.iter().any(|m| m.mark_type == "strike");
4091
4092    if has_code {
4093        // Code marks override other formatting in markdown.
4094        // However, annotation marks must still be preserved via bracketed-span syntax.
4095        let annotations: Vec<&AdfMark> = marks
4096            .iter()
4097            .filter(|m| m.mark_type == "annotation")
4098            .collect();
4099
4100        let mut code_str = String::new();
4101        if let Some(link_mark) = has_link {
4102            let href = link_href(link_mark);
4103            code_str.push('[');
4104            code_str.push('`');
4105            code_str.push_str(text);
4106            code_str.push('`');
4107            code_str.push_str("](");
4108            code_str.push_str(href);
4109            code_str.push(')');
4110        } else {
4111            code_str.push('`');
4112            code_str.push_str(text);
4113            code_str.push('`');
4114        }
4115
4116        if annotations.is_empty() {
4117            output.push_str(&code_str);
4118        } else {
4119            let mut attr_parts = Vec::new();
4120            for ann in &annotations {
4121                if let Some(ref attrs) = ann.attrs {
4122                    if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4123                        let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4124                        attr_parts.push(format!("annotation-id=\"{escaped}\""));
4125                    }
4126                    if let Some(at) = attrs
4127                        .get("annotationType")
4128                        .and_then(serde_json::Value::as_str)
4129                    {
4130                        attr_parts.push(format!("annotation-type={at}"));
4131                    }
4132                }
4133            }
4134            output.push('[');
4135            output.push_str(&code_str);
4136            output.push_str("]{");
4137            output.push_str(&attr_parts.join(" "));
4138            output.push('}');
4139        }
4140        return;
4141    }
4142
4143    // Helper: check if a formatting mark appears before the link mark.
4144    let is_before_link = |mark_type: &str| -> bool {
4145        if let Some(lp) = link_pos {
4146            marks[..lp].iter().any(|m| m.mark_type == mark_type)
4147        } else {
4148            false
4149        }
4150    };
4151
4152    // Partition formatting marks into outer (before link) and inner (after link / no link).
4153    let mut outer_strike = has_strike && is_before_link("strike");
4154    let mut outer_strong = has_strong && is_before_link("strong");
4155    let mut outer_em = has_em && is_before_link("em");
4156    let inner_strike = has_strike && !outer_strike;
4157    let inner_strong = has_strong && !outer_strong;
4158    let inner_em = has_em && !outer_em;
4159
4160    // Build the innermost formatted text.
4161    let mut inner = String::new();
4162    if inner_strike {
4163        inner.push_str("~~");
4164    }
4165    if inner_strong {
4166        inner.push_str("**");
4167    }
4168    if inner_em {
4169        inner.push('*');
4170    }
4171    let escaped = escape_emphasis_markers(text);
4172    let escaped = escape_emoji_shortcodes(&escaped);
4173    let escaped = escape_backticks(&escaped);
4174    let escaped = if has_link.is_some() {
4175        escape_link_brackets(&escaped)
4176    } else {
4177        escape_bare_urls(&escaped)
4178    };
4179    inner.push_str(&escaped);
4180    if inner_em {
4181        inner.push('*');
4182    }
4183    if inner_strong {
4184        inner.push_str("**");
4185    }
4186    if inner_strike {
4187        inner.push_str("~~");
4188    }
4189
4190    // Check for span-style marks (textColor, backgroundColor, subsup)
4191    let text_color = marks.iter().find(|m| m.mark_type == "textColor");
4192    let bg_color = marks.iter().find(|m| m.mark_type == "backgroundColor");
4193    let subsup = marks.iter().find(|m| m.mark_type == "subsup");
4194    let has_underline = marks.iter().any(|m| m.mark_type == "underline");
4195    let annotations: Vec<&AdfMark> = marks
4196        .iter()
4197        .filter(|m| m.mark_type == "annotation")
4198        .collect();
4199
4200    let needs_span = text_color.is_some() || bg_color.is_some() || subsup.is_some();
4201
4202    // Build the core content (with span/bracketed/link wrapping).
4203    let mut core = String::new();
4204    if needs_span {
4205        // Wrap in :span[text]{attrs} syntax
4206        let mut attr_parts = Vec::new();
4207        if let Some(m) = text_color {
4208            if let Some(c) = m
4209                .attrs
4210                .as_ref()
4211                .and_then(|a| a.get("color"))
4212                .and_then(serde_json::Value::as_str)
4213            {
4214                attr_parts.push(format!("color={c}"));
4215            }
4216        }
4217        if let Some(m) = bg_color {
4218            if let Some(c) = m
4219                .attrs
4220                .as_ref()
4221                .and_then(|a| a.get("color"))
4222                .and_then(serde_json::Value::as_str)
4223            {
4224                attr_parts.push(format!("bg={c}"));
4225            }
4226        }
4227        if let Some(m) = subsup {
4228            if let Some(kind) = m
4229                .attrs
4230                .as_ref()
4231                .and_then(|a| a.get("type"))
4232                .and_then(serde_json::Value::as_str)
4233            {
4234                attr_parts.push(kind.to_string());
4235            }
4236        }
4237        let span = format!(":span[{inner}]{{{}}}", attr_parts.join(" "));
4238        if let Some(link_mark) = has_link {
4239            let href = link_href(link_mark);
4240            if is_before_link("textColor")
4241                || is_before_link("backgroundColor")
4242                || is_before_link("subsup")
4243            {
4244                // Span wraps the link: :span[[text](url)]{attrs}
4245                let link_part = format!("[{inner}]({href})");
4246                core = format!(":span[{link_part}]{{{}}}", attr_parts.join(" "));
4247            } else {
4248                // Link wraps the span: [:span[text]{attrs}](url)
4249                core.push('[');
4250                core.push_str(&span);
4251                core.push_str("](");
4252                core.push_str(href);
4253                core.push(')');
4254            }
4255        } else {
4256            core.push_str(&span);
4257        }
4258    } else if has_underline || !annotations.is_empty() {
4259        let mut attr_parts = Vec::new();
4260        if has_underline {
4261            attr_parts.push("underline".to_string());
4262        }
4263        for ann in &annotations {
4264            if let Some(ref attrs) = ann.attrs {
4265                if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4266                    let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4267                    attr_parts.push(format!("annotation-id=\"{escaped}\""));
4268                }
4269                if let Some(at) = attrs
4270                    .get("annotationType")
4271                    .and_then(serde_json::Value::as_str)
4272                {
4273                    attr_parts.push(format!("annotation-type={at}"));
4274                }
4275            }
4276        }
4277        let bracketed = format!("[{inner}]{{{}}}", attr_parts.join(" "));
4278        if let Some(link_mark) = has_link {
4279            let href = link_href(link_mark);
4280            if is_before_link("underline")
4281                || link_pos
4282                    .is_some_and(|lp| marks[..lp].iter().any(|m| m.mark_type == "annotation"))
4283            {
4284                // Bracketed span wraps the link: [[text](url)]{underline}
4285                // Outer formatting marks that appear after underline in the
4286                // original mark array must go inside the brackets so that
4287                // round-trip parsing restores the original mark order.
4288                let underline_pos = marks.iter().position(|m| m.mark_type == "underline");
4289                let bracket_inner_strike = outer_strike
4290                    && underline_pos.is_some_and(|up| {
4291                        marks
4292                            .iter()
4293                            .position(|m| m.mark_type == "strike")
4294                            .is_some_and(|sp| sp > up)
4295                    });
4296                let bracket_inner_strong = outer_strong
4297                    && underline_pos.is_some_and(|up| {
4298                        marks
4299                            .iter()
4300                            .position(|m| m.mark_type == "strong")
4301                            .is_some_and(|sp| sp > up)
4302                    });
4303                let bracket_inner_em = outer_em
4304                    && underline_pos.is_some_and(|up| {
4305                        marks
4306                            .iter()
4307                            .position(|m| m.mark_type == "em")
4308                            .is_some_and(|sp| sp > up)
4309                    });
4310
4311                let mut bracket_content = String::new();
4312                if bracket_inner_strike {
4313                    bracket_content.push_str("~~");
4314                }
4315                if bracket_inner_strong {
4316                    bracket_content.push_str("**");
4317                }
4318                if bracket_inner_em {
4319                    bracket_content.push('*');
4320                }
4321                bracket_content.push_str(&format!("[{inner}]({href})"));
4322                if bracket_inner_em {
4323                    bracket_content.push('*');
4324                }
4325                if bracket_inner_strong {
4326                    bracket_content.push_str("**");
4327                }
4328                if bracket_inner_strike {
4329                    bracket_content.push_str("~~");
4330                }
4331
4332                if bracket_inner_strike {
4333                    outer_strike = false;
4334                }
4335                if bracket_inner_strong {
4336                    outer_strong = false;
4337                }
4338                if bracket_inner_em {
4339                    outer_em = false;
4340                }
4341
4342                core = format!("[{bracket_content}]{{{}}}", attr_parts.join(" "));
4343            } else {
4344                // Link wraps the bracketed span: [[text]{underline}](url)
4345                core.push('[');
4346                core.push_str(&bracketed);
4347                core.push_str("](");
4348                core.push_str(href);
4349                core.push(')');
4350            }
4351        } else {
4352            core.push_str(&bracketed);
4353        }
4354    } else if let Some(link_mark) = has_link {
4355        let href = link_href(link_mark);
4356        core.push('[');
4357        core.push_str(&inner);
4358        core.push_str("](");
4359        core.push_str(href);
4360        core.push(')');
4361    } else {
4362        core.push_str(&inner);
4363    }
4364
4365    // Apply outer formatting wrappers (marks that appeared before link).
4366    if outer_strike {
4367        output.push_str("~~");
4368    }
4369    if outer_strong {
4370        output.push_str("**");
4371    }
4372    if outer_em {
4373        output.push('*');
4374    }
4375    output.push_str(&core);
4376    if outer_em {
4377        output.push('*');
4378    }
4379    if outer_strong {
4380        output.push_str("**");
4381    }
4382    if outer_strike {
4383        output.push_str("~~");
4384    }
4385}
4386
4387/// Extracts the href from a link mark.
4388fn link_href(mark: &AdfMark) -> &str {
4389    mark.attrs
4390        .as_ref()
4391        .and_then(|a| a.get("href"))
4392        .and_then(serde_json::Value::as_str)
4393        .unwrap_or("")
4394}
4395
4396#[cfg(test)]
4397#[allow(clippy::unwrap_used, clippy::expect_used)]
4398mod tests {
4399    use super::*;
4400
4401    // ── markdown_to_adf tests ───────────────────────────────────────
4402
4403    #[test]
4404    fn paragraph() {
4405        let doc = markdown_to_adf("Hello world").unwrap();
4406        assert_eq!(doc.content.len(), 1);
4407        assert_eq!(doc.content[0].node_type, "paragraph");
4408    }
4409
4410    #[test]
4411    fn heading_levels() {
4412        for level in 1..=6 {
4413            let hashes = "#".repeat(level);
4414            let md = format!("{hashes} Title");
4415            let doc = markdown_to_adf(&md).unwrap();
4416            assert_eq!(doc.content[0].node_type, "heading");
4417            let attrs = doc.content[0].attrs.as_ref().unwrap();
4418            assert_eq!(attrs["level"], level as u64);
4419        }
4420    }
4421
4422    #[test]
4423    fn code_block() {
4424        let md = "```rust\nfn main() {}\n```";
4425        let doc = markdown_to_adf(md).unwrap();
4426        assert_eq!(doc.content[0].node_type, "codeBlock");
4427        let attrs = doc.content[0].attrs.as_ref().unwrap();
4428        assert_eq!(attrs["language"], "rust");
4429    }
4430
4431    #[test]
4432    fn code_block_no_language() {
4433        let md = "```\nsome code\n```";
4434        let doc = markdown_to_adf(md).unwrap();
4435        assert_eq!(doc.content[0].node_type, "codeBlock");
4436        assert!(doc.content[0].attrs.is_none());
4437    }
4438
4439    #[test]
4440    fn code_block_empty_language() {
4441        let md = "```\"\"\nsome code\n```";
4442        let doc = markdown_to_adf(md).unwrap();
4443        assert_eq!(doc.content[0].node_type, "codeBlock");
4444        let attrs = doc.content[0].attrs.as_ref().unwrap();
4445        assert_eq!(attrs["language"], "");
4446    }
4447
4448    #[test]
4449    fn horizontal_rule() {
4450        let doc = markdown_to_adf("---").unwrap();
4451        assert_eq!(doc.content[0].node_type, "rule");
4452    }
4453
4454    #[test]
4455    fn horizontal_rule_stars() {
4456        let doc = markdown_to_adf("***").unwrap();
4457        assert_eq!(doc.content[0].node_type, "rule");
4458    }
4459
4460    #[test]
4461    fn blockquote() {
4462        let md = "> This is a quote\n> Second line";
4463        let doc = markdown_to_adf(md).unwrap();
4464        assert_eq!(doc.content[0].node_type, "blockquote");
4465    }
4466
4467    #[test]
4468    fn bullet_list() {
4469        let md = "- Item 1\n- Item 2\n- Item 3";
4470        let doc = markdown_to_adf(md).unwrap();
4471        assert_eq!(doc.content[0].node_type, "bulletList");
4472        let items = doc.content[0].content.as_ref().unwrap();
4473        assert_eq!(items.len(), 3);
4474    }
4475
4476    #[test]
4477    fn ordered_list() {
4478        let md = "1. First\n2. Second\n3. Third";
4479        let doc = markdown_to_adf(md).unwrap();
4480        assert_eq!(doc.content[0].node_type, "orderedList");
4481        let items = doc.content[0].content.as_ref().unwrap();
4482        assert_eq!(items.len(), 3);
4483    }
4484
4485    #[test]
4486    fn task_list() {
4487        let md = "- [ ] Todo item\n- [x] Done item";
4488        let doc = markdown_to_adf(md).unwrap();
4489        assert_eq!(doc.content[0].node_type, "taskList");
4490        let items = doc.content[0].content.as_ref().unwrap();
4491        assert_eq!(items.len(), 2);
4492        assert_eq!(items[0].node_type, "taskItem");
4493        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
4494        assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
4495    }
4496
4497    #[test]
4498    fn task_list_uppercase_x() {
4499        let md = "- [X] Done item";
4500        let doc = markdown_to_adf(md).unwrap();
4501        assert_eq!(doc.content[0].node_type, "taskList");
4502        let item = &doc.content[0].content.as_ref().unwrap()[0];
4503        assert_eq!(item.attrs.as_ref().unwrap()["state"], "DONE");
4504    }
4505
4506    #[test]
4507    fn adf_task_list_to_markdown() {
4508        let doc = AdfDocument {
4509            version: 1,
4510            doc_type: "doc".to_string(),
4511            content: vec![AdfNode::task_list(vec![
4512                AdfNode::task_item(
4513                    "TODO",
4514                    vec![AdfNode::paragraph(vec![AdfNode::text("Todo")])],
4515                ),
4516                AdfNode::task_item(
4517                    "DONE",
4518                    vec![AdfNode::paragraph(vec![AdfNode::text("Done")])],
4519                ),
4520            ])],
4521        };
4522        let md = adf_to_markdown(&doc).unwrap();
4523        assert!(md.contains("- [ ] Todo"));
4524        assert!(md.contains("- [x] Done"));
4525    }
4526
4527    #[test]
4528    fn round_trip_task_list() {
4529        let md = "- [ ] Todo item\n- [x] Done item\n";
4530        let doc = markdown_to_adf(md).unwrap();
4531        let result = adf_to_markdown(&doc).unwrap();
4532        assert!(result.contains("- [ ] Todo item"));
4533        assert!(result.contains("- [x] Done item"));
4534    }
4535
4536    /// Issue #408: taskItem content with inline nodes directly (no paragraph wrapper).
4537    #[test]
4538    fn adf_task_item_unwrapped_inline_content() {
4539        // Real Confluence ADF: taskItem contains text nodes directly, no paragraph.
4540        let json = r#"{
4541            "version": 1,
4542            "type": "doc",
4543            "content": [{
4544                "type": "taskList",
4545                "attrs": {"localId": "list-001"},
4546                "content": [{
4547                    "type": "taskItem",
4548                    "attrs": {"localId": "task-001", "state": "TODO"},
4549                    "content": [{"type": "text", "text": "Do something"}]
4550                }]
4551            }]
4552        }"#;
4553        let doc: AdfDocument = serde_json::from_str(json).unwrap();
4554        let md = adf_to_markdown(&doc).unwrap();
4555        assert!(md.contains("- [ ] Do something"), "got: {md}");
4556        assert!(!md.contains("adf-unsupported"), "got: {md}");
4557    }
4558
4559    /// Issue #408: multiple taskItems with unwrapped inline content.
4560    #[test]
4561    fn adf_task_list_multiple_unwrapped_items() {
4562        let json = r#"{
4563            "version": 1,
4564            "type": "doc",
4565            "content": [{
4566                "type": "taskList",
4567                "attrs": {"localId": "list-001"},
4568                "content": [
4569                    {
4570                        "type": "taskItem",
4571                        "attrs": {"localId": "task-001", "state": "TODO"},
4572                        "content": [{"type": "text", "text": "First task"}]
4573                    },
4574                    {
4575                        "type": "taskItem",
4576                        "attrs": {"localId": "task-002", "state": "DONE"},
4577                        "content": [{"type": "text", "text": "Second task"}]
4578                    }
4579                ]
4580            }]
4581        }"#;
4582        let doc: AdfDocument = serde_json::from_str(json).unwrap();
4583        let md = adf_to_markdown(&doc).unwrap();
4584        assert!(md.contains("- [ ] First task"), "got: {md}");
4585        assert!(md.contains("- [x] Second task"), "got: {md}");
4586        assert!(!md.contains("adf-unsupported"), "got: {md}");
4587    }
4588
4589    /// Issue #408: unwrapped inline content with marks (bold text).
4590    #[test]
4591    fn adf_task_item_unwrapped_inline_with_marks() {
4592        let json = r#"{
4593            "version": 1,
4594            "type": "doc",
4595            "content": [{
4596                "type": "taskList",
4597                "attrs": {"localId": "list-001"},
4598                "content": [{
4599                    "type": "taskItem",
4600                    "attrs": {"localId": "task-001", "state": "TODO"},
4601                    "content": [
4602                        {"type": "text", "text": "Buy "},
4603                        {"type": "text", "text": "groceries", "marks": [{"type": "strong"}]},
4604                        {"type": "text", "text": " today"}
4605                    ]
4606                }]
4607            }]
4608        }"#;
4609        let doc: AdfDocument = serde_json::from_str(json).unwrap();
4610        let md = adf_to_markdown(&doc).unwrap();
4611        assert!(md.contains("- [ ] Buy **groceries** today"), "got: {md}");
4612    }
4613
4614    /// Issue #408: taskItem localId is preserved for unwrapped inline content.
4615    #[test]
4616    fn adf_task_item_unwrapped_preserves_local_id() {
4617        let json = r#"{
4618            "version": 1,
4619            "type": "doc",
4620            "content": [{
4621                "type": "taskList",
4622                "attrs": {"localId": "list-001"},
4623                "content": [{
4624                    "type": "taskItem",
4625                    "attrs": {"localId": "task-001", "state": "TODO"},
4626                    "content": [{"type": "text", "text": "Do something"}]
4627                }]
4628            }]
4629        }"#;
4630        let doc: AdfDocument = serde_json::from_str(json).unwrap();
4631        let md = adf_to_markdown(&doc).unwrap();
4632        assert!(md.contains("{localId=task-001}"), "got: {md}");
4633        assert!(md.contains("{localId=list-001}"), "got: {md}");
4634    }
4635
4636    /// Issue #408: round-trip from Confluence ADF with unwrapped taskItem content.
4637    #[test]
4638    fn round_trip_task_list_unwrapped_inline() {
4639        let json = r#"{
4640            "version": 1,
4641            "type": "doc",
4642            "content": [{
4643                "type": "taskList",
4644                "attrs": {"localId": "list-001"},
4645                "content": [
4646                    {
4647                        "type": "taskItem",
4648                        "attrs": {"localId": "task-001", "state": "TODO"},
4649                        "content": [{"type": "text", "text": "Do something"}]
4650                    },
4651                    {
4652                        "type": "taskItem",
4653                        "attrs": {"localId": "task-002", "state": "DONE"},
4654                        "content": [{"type": "text", "text": "Already done"}]
4655                    }
4656                ]
4657            }]
4658        }"#;
4659        let doc: AdfDocument = serde_json::from_str(json).unwrap();
4660        let md = adf_to_markdown(&doc).unwrap();
4661
4662        // Round-trip: markdown back to ADF
4663        let doc2 = markdown_to_adf(&md).unwrap();
4664        assert_eq!(doc2.content[0].node_type, "taskList");
4665
4666        let items = doc2.content[0].content.as_ref().unwrap();
4667        assert_eq!(items.len(), 2);
4668        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
4669        assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
4670
4671        // localIds preserved
4672        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "task-001");
4673        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "task-002");
4674        assert_eq!(
4675            doc2.content[0].attrs.as_ref().unwrap()["localId"],
4676            "list-001"
4677        );
4678    }
4679
4680    /// Issue #408: taskItem with inline content followed by a nested block (sub-list).
4681    #[test]
4682    fn adf_task_item_unwrapped_inline_then_block() {
4683        let json = r#"{
4684            "version": 1,
4685            "type": "doc",
4686            "content": [{
4687                "type": "taskList",
4688                "attrs": {"localId": "list-001"},
4689                "content": [{
4690                    "type": "taskItem",
4691                    "attrs": {"localId": "task-001", "state": "TODO"},
4692                    "content": [
4693                        {"type": "text", "text": "Parent task"},
4694                        {
4695                            "type": "bulletList",
4696                            "content": [{
4697                                "type": "listItem",
4698                                "content": [{
4699                                    "type": "paragraph",
4700                                    "content": [{"type": "text", "text": "sub-item"}]
4701                                }]
4702                            }]
4703                        }
4704                    ]
4705                }]
4706            }]
4707        }"#;
4708        let doc: AdfDocument = serde_json::from_str(json).unwrap();
4709        let md = adf_to_markdown(&doc).unwrap();
4710        assert!(md.contains("- [ ] Parent task"), "got: {md}");
4711        assert!(md.contains("  - sub-item"), "got: {md}");
4712        assert!(!md.contains("adf-unsupported"), "got: {md}");
4713    }
4714
4715    /// Issue #408: taskItem with empty content array renders without panic.
4716    #[test]
4717    fn adf_task_item_empty_content() {
4718        let json = r#"{
4719            "version": 1,
4720            "type": "doc",
4721            "content": [{
4722                "type": "taskList",
4723                "attrs": {"localId": "list-001"},
4724                "content": [{
4725                    "type": "taskItem",
4726                    "attrs": {"localId": "task-001", "state": "TODO"},
4727                    "content": []
4728                }]
4729            }]
4730        }"#;
4731        let doc: AdfDocument = serde_json::from_str(json).unwrap();
4732        let md = adf_to_markdown(&doc).unwrap();
4733        assert!(md.contains("- [ ] "), "got: {md}");
4734        assert!(!md.contains("adf-unsupported"), "got: {md}");
4735    }
4736
4737    /// Issue #489: nested taskItem inside taskItem.content renders as indented
4738    /// task items instead of corrupting the surrounding taskList.
4739    #[test]
4740    fn adf_nested_task_item_renders_without_corruption() {
4741        let json = r#"{
4742            "type": "doc",
4743            "version": 1,
4744            "content": [{
4745                "type": "taskList",
4746                "attrs": {"localId": ""},
4747                "content": [
4748                    {
4749                        "type": "taskItem",
4750                        "attrs": {"localId": "aabbccdd-1234-5678-abcd-aabbccdd1234", "state": "TODO"},
4751                        "content": [{"type": "text", "text": "Normal task"}]
4752                    },
4753                    {
4754                        "type": "taskItem",
4755                        "attrs": {"localId": ""},
4756                        "content": [
4757                            {
4758                                "type": "taskItem",
4759                                "attrs": {"localId": "bbccddee-2345-6789-bcde-bbccddee2345", "state": "TODO"},
4760                                "content": [{"type": "text", "text": "Nested task one"}]
4761                            },
4762                            {
4763                                "type": "taskItem",
4764                                "attrs": {"localId": "ccddee11-3456-7890-cdef-ccddee113456", "state": "DONE"},
4765                                "content": [{"type": "text", "text": "Nested task two"}]
4766                            }
4767                        ]
4768                    }
4769                ]
4770            }]
4771        }"#;
4772        let doc: AdfDocument = serde_json::from_str(json).unwrap();
4773        let md = adf_to_markdown(&doc).unwrap();
4774        // Normal task preserved
4775        assert!(md.contains("- [ ] Normal task"), "got: {md}");
4776        // Nested tasks rendered as indented task items, not adf-unsupported
4777        assert!(!md.contains("adf-unsupported"), "got: {md}");
4778        assert!(md.contains("  - [ ] Nested task one"), "got: {md}");
4779        assert!(md.contains("  - [x] Nested task two"), "got: {md}");
4780    }
4781
4782    /// Issue #489: round-trip of nested taskItem preserves data.
4783    #[test]
4784    fn round_trip_nested_task_item() {
4785        let json = r#"{
4786            "type": "doc",
4787            "version": 1,
4788            "content": [{
4789                "type": "taskList",
4790                "attrs": {"localId": ""},
4791                "content": [
4792                    {
4793                        "type": "taskItem",
4794                        "attrs": {"localId": "task-001", "state": "TODO"},
4795                        "content": [{"type": "text", "text": "Normal task"}]
4796                    },
4797                    {
4798                        "type": "taskItem",
4799                        "attrs": {"localId": ""},
4800                        "content": [
4801                            {
4802                                "type": "taskItem",
4803                                "attrs": {"localId": "task-002", "state": "TODO"},
4804                                "content": [{"type": "text", "text": "Nested one"}]
4805                            },
4806                            {
4807                                "type": "taskItem",
4808                                "attrs": {"localId": "task-003", "state": "DONE"},
4809                                "content": [{"type": "text", "text": "Nested two"}]
4810                            }
4811                        ]
4812                    }
4813                ]
4814            }]
4815        }"#;
4816        let doc: AdfDocument = serde_json::from_str(json).unwrap();
4817        let md = adf_to_markdown(&doc).unwrap();
4818        let doc2 = markdown_to_adf(&md).unwrap();
4819
4820        // Top-level structure: taskList with 2 items
4821        assert_eq!(doc2.content[0].node_type, "taskList");
4822        let items = doc2.content[0].content.as_ref().unwrap();
4823        assert_eq!(items.len(), 2, "expected 2 top-level items, got: {items:?}");
4824
4825        // First item: normal task preserved
4826        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
4827        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "task-001");
4828        let first_content = items[0].content.as_ref().unwrap();
4829        assert_eq!(first_content[0].text.as_deref(), Some("Normal task"));
4830
4831        // Second item: container taskItem — no spurious `state` attr
4832        let container = &items[1];
4833        assert_eq!(container.node_type, "taskItem");
4834        let c_attrs = container.attrs.as_ref().unwrap();
4835        assert!(
4836            c_attrs.get("state").is_none(),
4837            "container should have no state attr, got: {c_attrs:?}"
4838        );
4839
4840        // Children are bare taskItems, NOT wrapped in a taskList
4841        let container_content = container.content.as_ref().unwrap();
4842        assert_eq!(
4843            container_content.len(),
4844            2,
4845            "expected 2 bare taskItem children"
4846        );
4847        assert_eq!(container_content[0].node_type, "taskItem");
4848        assert_eq!(
4849            container_content[0].attrs.as_ref().unwrap()["state"],
4850            "TODO"
4851        );
4852        assert_eq!(
4853            container_content[0].attrs.as_ref().unwrap()["localId"],
4854            "task-002"
4855        );
4856        assert_eq!(container_content[1].node_type, "taskItem");
4857        assert_eq!(
4858            container_content[1].attrs.as_ref().unwrap()["state"],
4859            "DONE"
4860        );
4861        assert_eq!(
4862            container_content[1].attrs.as_ref().unwrap()["localId"],
4863            "task-003"
4864        );
4865    }
4866
4867    /// Issue #489: nested taskItem with localIds on both container and children.
4868    #[test]
4869    fn adf_nested_task_item_preserves_local_ids() {
4870        let json = r#"{
4871            "type": "doc",
4872            "version": 1,
4873            "content": [{
4874                "type": "taskList",
4875                "attrs": {"localId": "list-001"},
4876                "content": [{
4877                    "type": "taskItem",
4878                    "attrs": {"localId": "container-001", "state": "TODO"},
4879                    "content": [{
4880                        "type": "taskItem",
4881                        "attrs": {"localId": "child-001", "state": "DONE"},
4882                        "content": [{"type": "text", "text": "Nested child"}]
4883                    }]
4884                }]
4885            }]
4886        }"#;
4887        let doc: AdfDocument = serde_json::from_str(json).unwrap();
4888        let md = adf_to_markdown(&doc).unwrap();
4889        // Container localId is emitted
4890        assert!(
4891            md.contains("localId=container-001"),
4892            "container localId missing: {md}"
4893        );
4894        // Child localId is emitted
4895        assert!(
4896            md.contains("localId=child-001"),
4897            "child localId missing: {md}"
4898        );
4899        assert!(!md.contains("adf-unsupported"), "got: {md}");
4900    }
4901
4902    /// Issue #489: nested taskItem content mixed with a non-taskItem block node.
4903    /// Covers the else branch in the renderer where a child is not a taskItem.
4904    #[test]
4905    fn adf_nested_task_item_mixed_with_block_node() {
4906        let json = r#"{
4907            "type": "doc",
4908            "version": 1,
4909            "content": [{
4910                "type": "taskList",
4911                "attrs": {"localId": ""},
4912                "content": [{
4913                    "type": "taskItem",
4914                    "attrs": {"localId": "", "state": "TODO"},
4915                    "content": [
4916                        {
4917                            "type": "taskItem",
4918                            "attrs": {"localId": "", "state": "TODO"},
4919                            "content": [{"type": "text", "text": "A nested task"}]
4920                        },
4921                        {
4922                            "type": "paragraph",
4923                            "content": [{"type": "text", "text": "Stray paragraph"}]
4924                        }
4925                    ]
4926                }]
4927            }]
4928        }"#;
4929        let doc: AdfDocument = serde_json::from_str(json).unwrap();
4930        let md = adf_to_markdown(&doc).unwrap();
4931        assert!(md.contains("  - [ ] A nested task"), "got: {md}");
4932        assert!(md.contains("  Stray paragraph"), "got: {md}");
4933        assert!(!md.contains("adf-unsupported"), "got: {md}");
4934    }
4935
4936    /// Issue #489: task item with inline text AND indented sub-content.
4937    /// Covers the parser's `Some` branch when appending nested blocks to
4938    /// an existing content vec.
4939    #[test]
4940    fn task_item_with_text_and_nested_sub_content() {
4941        let md = "- [ ] Parent task\n  - [ ] Sub task\n";
4942        let doc = markdown_to_adf(md).unwrap();
4943        assert_eq!(doc.content[0].node_type, "taskList");
4944        let items = doc.content[0].content.as_ref().unwrap();
4945        // Issue #506: the nested taskList is a sibling of the taskItem,
4946        // not a child — matching ADF's canonical structure.
4947        assert_eq!(items.len(), 2, "got: {items:?}");
4948        let parent = &items[0];
4949        assert_eq!(parent.attrs.as_ref().unwrap()["state"], "TODO");
4950        let parent_content = parent.content.as_ref().unwrap();
4951        assert_eq!(parent_content[0].text.as_deref(), Some("Parent task"));
4952        // Second item: nested taskList (sibling)
4953        assert_eq!(items[1].node_type, "taskList");
4954        let nested = items[1].content.as_ref().unwrap();
4955        assert_eq!(nested.len(), 1);
4956        assert_eq!(nested[0].attrs.as_ref().unwrap()["state"], "TODO");
4957    }
4958
4959    /// Issue #489: empty task item with non-taskList sub-content (e.g. a
4960    /// paragraph).  Exercises the `None` branch when the sub-content does
4961    /// not qualify for container-unwrap.
4962    #[test]
4963    fn task_item_empty_with_non_tasklist_sub_content() {
4964        let md = "- [ ] \n  Some paragraph text\n";
4965        let doc = markdown_to_adf(md).unwrap();
4966        assert_eq!(doc.content[0].node_type, "taskList");
4967        let items = doc.content[0].content.as_ref().unwrap();
4968        assert_eq!(items.len(), 1);
4969        let item = &items[0];
4970        assert_eq!(item.attrs.as_ref().unwrap()["state"], "TODO");
4971        let content = item.content.as_ref().unwrap();
4972        // Sub-content is a paragraph (not unwrapped since it's not a taskList)
4973        assert_eq!(content[0].node_type, "paragraph");
4974    }
4975
4976    /// Issue #489: single nested taskItem (edge case — only one child).
4977    #[test]
4978    fn adf_nested_task_item_single_child() {
4979        let json = r#"{
4980            "type": "doc",
4981            "version": 1,
4982            "content": [{
4983                "type": "taskList",
4984                "attrs": {"localId": ""},
4985                "content": [{
4986                    "type": "taskItem",
4987                    "attrs": {"localId": "", "state": "TODO"},
4988                    "content": [{
4989                        "type": "taskItem",
4990                        "attrs": {"localId": "", "state": "DONE"},
4991                        "content": [{"type": "text", "text": "Only child"}]
4992                    }]
4993                }]
4994            }]
4995        }"#;
4996        let doc: AdfDocument = serde_json::from_str(json).unwrap();
4997        let md = adf_to_markdown(&doc).unwrap();
4998        assert!(md.contains("  - [x] Only child"), "got: {md}");
4999        assert!(!md.contains("adf-unsupported"), "got: {md}");
5000    }
5001
5002    /// Issue #506: nested taskList as direct child of outer taskList is
5003    /// rendered indented so it round-trips back as taskList, not taskItem.
5004    #[test]
5005    fn adf_nested_tasklist_sibling_renders_indented() {
5006        let json = r#"{
5007            "version": 1,
5008            "type": "doc",
5009            "content": [{
5010                "type": "taskList",
5011                "attrs": {"localId": ""},
5012                "content": [
5013                    {
5014                        "type": "taskItem",
5015                        "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000001", "state": "TODO"},
5016                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task one"}]}]
5017                    },
5018                    {
5019                        "type": "taskList",
5020                        "attrs": {"localId": ""},
5021                        "content": [{
5022                            "type": "taskItem",
5023                            "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000002", "state": "TODO"},
5024                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "nested sub-task"}]}]
5025                        }]
5026                    },
5027                    {
5028                        "type": "taskItem",
5029                        "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000003", "state": "TODO"},
5030                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task two"}]}]
5031                    }
5032                ]
5033            }]
5034        }"#;
5035        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5036        let md = adf_to_markdown(&doc).unwrap();
5037        // The nested taskList should be indented under the preceding item.
5038        assert!(md.contains("- [ ] parent task one"), "got: {md}");
5039        assert!(md.contains("  - [ ] nested sub-task"), "got: {md}");
5040        assert!(md.contains("- [ ] parent task two"), "got: {md}");
5041    }
5042
5043    /// Issue #506: round-trip preserves nested taskList type.
5044    #[test]
5045    fn round_trip_nested_tasklist_preserves_type() {
5046        let json = r#"{
5047            "version": 1,
5048            "type": "doc",
5049            "content": [{
5050                "type": "taskList",
5051                "attrs": {"localId": ""},
5052                "content": [
5053                    {
5054                        "type": "taskItem",
5055                        "attrs": {"localId": "", "state": "TODO"},
5056                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task one"}]}]
5057                    },
5058                    {
5059                        "type": "taskList",
5060                        "attrs": {"localId": ""},
5061                        "content": [{
5062                            "type": "taskItem",
5063                            "attrs": {"localId": "", "state": "TODO"},
5064                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "nested sub-task"}]}]
5065                        }]
5066                    },
5067                    {
5068                        "type": "taskItem",
5069                        "attrs": {"localId": "", "state": "TODO"},
5070                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task two"}]}]
5071                    }
5072                ]
5073            }]
5074        }"#;
5075        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5076        let md = adf_to_markdown(&doc).unwrap();
5077        let rt_doc = markdown_to_adf(&md).unwrap();
5078        // The outer taskList should still be present.
5079        assert_eq!(rt_doc.content[0].node_type, "taskList");
5080        let items = rt_doc.content[0].content.as_ref().unwrap();
5081        // The nested taskList is a sibling of the taskItem nodes,
5082        // matching the original ADF structure (issue #506).
5083        assert_eq!(items.len(), 3, "got: {items:?}");
5084        assert_eq!(items[0].node_type, "taskItem");
5085        assert_eq!(
5086            items[1].node_type, "taskList",
5087            "nested taskList should survive round-trip"
5088        );
5089        assert_eq!(items[2].node_type, "taskItem");
5090        let nested_items = items[1].content.as_ref().unwrap();
5091        assert_eq!(nested_items[0].attrs.as_ref().unwrap()["state"], "TODO");
5092    }
5093
5094    /// Issue #506: nested taskList with DONE state preserves checkbox.
5095    #[test]
5096    fn adf_nested_tasklist_done_state() {
5097        let json = r#"{
5098            "version": 1,
5099            "type": "doc",
5100            "content": [{
5101                "type": "taskList",
5102                "attrs": {"localId": ""},
5103                "content": [
5104                    {
5105                        "type": "taskItem",
5106                        "attrs": {"localId": "", "state": "TODO"},
5107                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent"}]}]
5108                    },
5109                    {
5110                        "type": "taskList",
5111                        "attrs": {"localId": ""},
5112                        "content": [{
5113                            "type": "taskItem",
5114                            "attrs": {"localId": "", "state": "DONE"},
5115                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "done child"}]}]
5116                        }]
5117                    }
5118                ]
5119            }]
5120        }"#;
5121        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5122        let md = adf_to_markdown(&doc).unwrap();
5123        assert!(md.contains("  - [x] done child"), "got: {md}");
5124        // Round-trip preserves DONE state — nested taskList is a sibling.
5125        let rt_doc = markdown_to_adf(&md).unwrap();
5126        let items = rt_doc.content[0].content.as_ref().unwrap();
5127        assert_eq!(
5128            items[1].node_type, "taskList",
5129            "nested taskList should survive round-trip"
5130        );
5131        let nested_item = &items[1].content.as_ref().unwrap()[0];
5132        assert_eq!(nested_item.attrs.as_ref().unwrap()["state"], "DONE");
5133    }
5134
5135    /// Issue #506: multiple nested taskLists at the same level.
5136    #[test]
5137    fn adf_multiple_nested_tasklists() {
5138        let json = r#"{
5139            "version": 1,
5140            "type": "doc",
5141            "content": [{
5142                "type": "taskList",
5143                "attrs": {"localId": ""},
5144                "content": [
5145                    {
5146                        "type": "taskItem",
5147                        "attrs": {"localId": "", "state": "TODO"},
5148                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "first parent"}]}]
5149                    },
5150                    {
5151                        "type": "taskList",
5152                        "attrs": {"localId": ""},
5153                        "content": [{
5154                            "type": "taskItem",
5155                            "attrs": {"localId": "", "state": "TODO"},
5156                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child A"}]}]
5157                        }]
5158                    },
5159                    {
5160                        "type": "taskItem",
5161                        "attrs": {"localId": "", "state": "TODO"},
5162                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "second parent"}]}]
5163                    },
5164                    {
5165                        "type": "taskList",
5166                        "attrs": {"localId": ""},
5167                        "content": [{
5168                            "type": "taskItem",
5169                            "attrs": {"localId": "", "state": "DONE"},
5170                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child B"}]}]
5171                        }]
5172                    }
5173                ]
5174            }]
5175        }"#;
5176        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5177        let md = adf_to_markdown(&doc).unwrap();
5178        assert!(md.contains("- [ ] first parent"), "got: {md}");
5179        assert!(md.contains("  - [ ] child A"), "got: {md}");
5180        assert!(md.contains("- [ ] second parent"), "got: {md}");
5181        assert!(md.contains("  - [x] child B"), "got: {md}");
5182    }
5183
5184    /// Issue #506: second round-trip is stable (idempotent after first
5185    /// structural normalisation).
5186    #[test]
5187    fn round_trip_nested_tasklist_stable() {
5188        let json = r#"{
5189            "version": 1,
5190            "type": "doc",
5191            "content": [{
5192                "type": "taskList",
5193                "attrs": {"localId": ""},
5194                "content": [
5195                    {
5196                        "type": "taskItem",
5197                        "attrs": {"localId": "", "state": "TODO"},
5198                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent"}]}]
5199                    },
5200                    {
5201                        "type": "taskList",
5202                        "attrs": {"localId": ""},
5203                        "content": [{
5204                            "type": "taskItem",
5205                            "attrs": {"localId": "", "state": "TODO"},
5206                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child"}]}]
5207                        }]
5208                    }
5209                ]
5210            }]
5211        }"#;
5212        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5213        // First round-trip.
5214        let md1 = adf_to_markdown(&doc).unwrap();
5215        let rt1 = markdown_to_adf(&md1).unwrap();
5216        // Second round-trip.
5217        let md2 = adf_to_markdown(&rt1).unwrap();
5218        let rt2 = markdown_to_adf(&md2).unwrap();
5219        // Markdown output should be identical after first normalisation.
5220        assert_eq!(md1, md2, "markdown should be stable across round-trips");
5221        // ADF structure should also be stable.
5222        let rt1_json = serde_json::to_string(&rt1).unwrap();
5223        let rt2_json = serde_json::to_string(&rt2).unwrap();
5224        assert_eq!(
5225            rt1_json, rt2_json,
5226            "ADF should be stable across round-trips"
5227        );
5228    }
5229
5230    /// Issue #506: task item with text and mixed indented sub-content
5231    /// (taskList + non-taskList block).  Exercises the `child_nodes` branch
5232    /// where non-taskList blocks stay as children of the taskItem while
5233    /// taskLists are promoted to siblings.
5234    #[test]
5235    fn task_item_mixed_sub_content_splits_siblings() {
5236        let md = "- [ ] Parent task\n  - [ ] Sub task\n  Some paragraph\n";
5237        let doc = markdown_to_adf(md).unwrap();
5238        let items = doc.content[0].content.as_ref().unwrap();
5239        // taskItem + sibling taskList
5240        assert_eq!(items.len(), 2, "got: {items:?}");
5241        assert_eq!(items[0].node_type, "taskItem");
5242        let parent_content = items[0].content.as_ref().unwrap();
5243        // Inline text + paragraph block (the non-taskList sub-content)
5244        assert!(
5245            parent_content.iter().any(|n| n.node_type == "paragraph"),
5246            "non-taskList sub-content should stay as child: {parent_content:?}"
5247        );
5248        // Sibling taskList
5249        assert_eq!(items[1].node_type, "taskList");
5250    }
5251
5252    /// Issue #506: empty task item with mixed indented sub-content hits the
5253    /// `None` arm of the `task.content` match when promoting taskLists to
5254    /// siblings.
5255    #[test]
5256    fn empty_task_item_mixed_sub_content_none_arm() {
5257        let md = "- [ ] \n  Some paragraph\n  - [ ] Sub task\n";
5258        let doc = markdown_to_adf(md).unwrap();
5259        let items = doc.content[0].content.as_ref().unwrap();
5260        // taskItem (with paragraph child) + sibling taskList
5261        assert_eq!(items.len(), 2, "got: {items:?}");
5262        assert_eq!(items[0].node_type, "taskItem");
5263        let parent_content = items[0].content.as_ref().unwrap();
5264        assert!(
5265            parent_content.iter().any(|n| n.node_type == "paragraph"),
5266            "paragraph should be assigned to taskItem: {parent_content:?}"
5267        );
5268        assert_eq!(items[1].node_type, "taskList");
5269    }
5270
5271    /// Issue #506: task item with text and only non-taskList sub-content
5272    /// (no sibling taskLists).  Exercises the fall-through path where
5273    /// `sibling_task_lists` is empty and child_nodes are appended to
5274    /// the existing task content (Some arm).
5275    #[test]
5276    fn task_item_text_with_non_tasklist_sub_content_only() {
5277        let md = "- [ ] My task\n  Extra paragraph content\n";
5278        let doc = markdown_to_adf(md).unwrap();
5279        let items = doc.content[0].content.as_ref().unwrap();
5280        // Single taskItem — no sibling taskLists to extract.
5281        assert_eq!(items.len(), 1, "got: {items:?}");
5282        assert_eq!(items[0].node_type, "taskItem");
5283        let content = items[0].content.as_ref().unwrap();
5284        // Inline text + sub-paragraph
5285        assert!(
5286            content.iter().any(|n| n.node_type == "paragraph"),
5287            "paragraph sub-content should be a child of taskItem: {content:?}"
5288        );
5289    }
5290
5291    /// Covers the else branch in render_list_item_content where the first
5292    /// child of a list item is a block node (not paragraph, not inline).
5293    #[test]
5294    fn adf_list_item_leading_block_node() {
5295        let json = r#"{
5296            "version": 1,
5297            "type": "doc",
5298            "content": [{
5299                "type": "bulletList",
5300                "content": [{
5301                    "type": "listItem",
5302                    "content": [{
5303                        "type": "codeBlock",
5304                        "attrs": {"language": "rust"},
5305                        "content": [{"type": "text", "text": "let x = 1;"}]
5306                    }]
5307                }]
5308            }]
5309        }"#;
5310        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5311        let md = adf_to_markdown(&doc).unwrap();
5312        assert!(md.contains("```rust"), "got: {md}");
5313        assert!(md.contains("let x = 1;"), "got: {md}");
5314        // Continuation lines must be indented so the block stays inside
5315        // the list item on round-trip (issue #511).
5316        for line in md.lines() {
5317            if line.starts_with("- ") {
5318                continue; // first line with list marker
5319            }
5320            if line.trim().is_empty() {
5321                continue;
5322            }
5323            assert!(
5324                line.starts_with("  "),
5325                "continuation line not indented: {line:?}"
5326            );
5327        }
5328    }
5329
5330    /// Round-trip a codeBlock inside a listItem whose content contains a
5331    /// backtick character — the exact reproducer from issue #511.
5332    #[test]
5333    fn code_block_in_list_item_backtick_roundtrip() {
5334        let json = r#"{
5335            "version": 1,
5336            "type": "doc",
5337            "content": [{
5338                "type": "bulletList",
5339                "content": [{
5340                    "type": "listItem",
5341                    "content": [{
5342                        "type": "codeBlock",
5343                        "attrs": {"language": ""},
5344                        "content": [{"type": "text", "text": "error: some value with a backtick ` at end"}]
5345                    }]
5346                }]
5347            }]
5348        }"#;
5349        let original: AdfDocument = serde_json::from_str(json).unwrap();
5350        let md = adf_to_markdown(&original).unwrap();
5351        let roundtripped = markdown_to_adf(&md).unwrap();
5352        let list = &roundtripped.content[0];
5353        assert_eq!(list.node_type, "bulletList", "top node: {}", list.node_type);
5354        let item = &list.content.as_ref().unwrap()[0];
5355        let first_child = &item.content.as_ref().unwrap()[0];
5356        assert_eq!(
5357            first_child.node_type, "codeBlock",
5358            "expected codeBlock, got: {}",
5359            first_child.node_type
5360        );
5361        let text = first_child.content.as_ref().unwrap()[0]
5362            .text
5363            .as_deref()
5364            .unwrap();
5365        assert_eq!(text, "error: some value with a backtick ` at end");
5366    }
5367
5368    /// Code block with language tag inside a list item round-trips.
5369    #[test]
5370    fn code_block_with_language_in_list_item_roundtrip() {
5371        let json = r#"{
5372            "version": 1,
5373            "type": "doc",
5374            "content": [{
5375                "type": "bulletList",
5376                "content": [{
5377                    "type": "listItem",
5378                    "content": [{
5379                        "type": "codeBlock",
5380                        "attrs": {"language": "rust"},
5381                        "content": [{"type": "text", "text": "fn main() {\n    println!(\"hello\");\n}"}]
5382                    }]
5383                }]
5384            }]
5385        }"#;
5386        let original: AdfDocument = serde_json::from_str(json).unwrap();
5387        let md = adf_to_markdown(&original).unwrap();
5388        let roundtripped = markdown_to_adf(&md).unwrap();
5389        let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
5390        let code = &item.content.as_ref().unwrap()[0];
5391        assert_eq!(code.node_type, "codeBlock");
5392        let lang = code
5393            .attrs
5394            .as_ref()
5395            .and_then(|a| a.get("language"))
5396            .and_then(serde_json::Value::as_str)
5397            .unwrap_or("");
5398        assert_eq!(lang, "rust");
5399        let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5400        assert!(text.contains("println!"), "code content: {text}");
5401    }
5402
5403    /// Code block in an ordered list item round-trips correctly.
5404    #[test]
5405    fn code_block_in_ordered_list_item_roundtrip() {
5406        let json = r#"{
5407            "version": 1,
5408            "type": "doc",
5409            "content": [{
5410                "type": "orderedList",
5411                "attrs": {"order": 1},
5412                "content": [{
5413                    "type": "listItem",
5414                    "content": [{
5415                        "type": "codeBlock",
5416                        "attrs": {"language": ""},
5417                        "content": [{"type": "text", "text": "backtick ` here"}]
5418                    }]
5419                }]
5420            }]
5421        }"#;
5422        let original: AdfDocument = serde_json::from_str(json).unwrap();
5423        let md = adf_to_markdown(&original).unwrap();
5424        let roundtripped = markdown_to_adf(&md).unwrap();
5425        let list = &roundtripped.content[0];
5426        assert_eq!(list.node_type, "orderedList");
5427        let item = &list.content.as_ref().unwrap()[0];
5428        let code = &item.content.as_ref().unwrap()[0];
5429        assert_eq!(code.node_type, "codeBlock");
5430        let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5431        assert_eq!(text, "backtick ` here");
5432    }
5433
5434    /// A list item with a code block followed by a paragraph round-trips.
5435    #[test]
5436    fn code_block_then_paragraph_in_list_item() {
5437        let json = r#"{
5438            "version": 1,
5439            "type": "doc",
5440            "content": [{
5441                "type": "bulletList",
5442                "content": [{
5443                    "type": "listItem",
5444                    "content": [
5445                        {
5446                            "type": "codeBlock",
5447                            "attrs": {"language": ""},
5448                            "content": [{"type": "text", "text": "code with ` backtick"}]
5449                        },
5450                        {
5451                            "type": "paragraph",
5452                            "content": [{"type": "text", "text": "description"}]
5453                        }
5454                    ]
5455                }]
5456            }]
5457        }"#;
5458        let original: AdfDocument = serde_json::from_str(json).unwrap();
5459        let md = adf_to_markdown(&original).unwrap();
5460        let roundtripped = markdown_to_adf(&md).unwrap();
5461        let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
5462        let children = item.content.as_ref().unwrap();
5463        assert_eq!(children[0].node_type, "codeBlock");
5464        assert_eq!(children[1].node_type, "paragraph");
5465    }
5466
5467    /// Multiple backticks in code block content round-trip.
5468    #[test]
5469    fn code_block_multiple_backticks_in_list_item() {
5470        let json = r#"{
5471            "version": 1,
5472            "type": "doc",
5473            "content": [{
5474                "type": "bulletList",
5475                "content": [{
5476                    "type": "listItem",
5477                    "content": [{
5478                        "type": "codeBlock",
5479                        "attrs": {"language": ""},
5480                        "content": [{"type": "text", "text": "a ` b `` c ``` d"}]
5481                    }]
5482                }]
5483            }]
5484        }"#;
5485        let original: AdfDocument = serde_json::from_str(json).unwrap();
5486        let md = adf_to_markdown(&original).unwrap();
5487        let roundtripped = markdown_to_adf(&md).unwrap();
5488        let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
5489        let code = &item.content.as_ref().unwrap()[0];
5490        assert_eq!(code.node_type, "codeBlock");
5491        let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5492        assert_eq!(text, "a ` b `` c ``` d");
5493    }
5494
5495    /// Media as the first child of a list item with a subsequent paragraph
5496    /// exercises the media + sub_lines branch in `parse_list_item_first_line`.
5497    #[test]
5498    fn media_first_child_with_sub_content_in_list_item() {
5499        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
5500          {"type":"listItem","content":[
5501            {"type":"mediaSingle","attrs":{"layout":"center"},
5502             "content":[{"type":"media","attrs":{"type":"file","id":"img-99","collection":"col-x","height":50,"width":100}}]},
5503            {"type":"paragraph","content":[{"type":"text","text":"Caption below"}]}
5504          ]}
5505        ]}]}"#;
5506        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
5507        let md = adf_to_markdown(&doc).unwrap();
5508        let rt = markdown_to_adf(&md).unwrap();
5509        let item = &rt.content[0].content.as_ref().unwrap()[0];
5510        let children = item.content.as_ref().unwrap();
5511        assert_eq!(
5512            children.len(),
5513            2,
5514            "expected 2 children, got {}",
5515            children.len()
5516        );
5517        assert_eq!(children[0].node_type, "mediaSingle");
5518        let media = &children[0].content.as_ref().unwrap()[0];
5519        assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-99");
5520        assert_eq!(children[1].node_type, "paragraph");
5521    }
5522
5523    #[test]
5524    fn inline_bold() {
5525        let doc = markdown_to_adf("Some **bold** text").unwrap();
5526        let content = doc.content[0].content.as_ref().unwrap();
5527        assert!(content.len() >= 3);
5528        let bold_node = &content[1];
5529        assert_eq!(bold_node.text.as_deref(), Some("bold"));
5530        let marks = bold_node.marks.as_ref().unwrap();
5531        assert_eq!(marks[0].mark_type, "strong");
5532    }
5533
5534    #[test]
5535    fn inline_italic() {
5536        let doc = markdown_to_adf("Some *italic* text").unwrap();
5537        let content = doc.content[0].content.as_ref().unwrap();
5538        let italic_node = &content[1];
5539        assert_eq!(italic_node.text.as_deref(), Some("italic"));
5540        let marks = italic_node.marks.as_ref().unwrap();
5541        assert_eq!(marks[0].mark_type, "em");
5542    }
5543
5544    #[test]
5545    fn inline_code() {
5546        let doc = markdown_to_adf("Use `code` here").unwrap();
5547        let content = doc.content[0].content.as_ref().unwrap();
5548        let code_node = &content[1];
5549        assert_eq!(code_node.text.as_deref(), Some("code"));
5550        let marks = code_node.marks.as_ref().unwrap();
5551        assert_eq!(marks[0].mark_type, "code");
5552    }
5553
5554    #[test]
5555    fn inline_strikethrough() {
5556        let doc = markdown_to_adf("Some ~~deleted~~ text").unwrap();
5557        let content = doc.content[0].content.as_ref().unwrap();
5558        let strike_node = &content[1];
5559        assert_eq!(strike_node.text.as_deref(), Some("deleted"));
5560        let marks = strike_node.marks.as_ref().unwrap();
5561        assert_eq!(marks[0].mark_type, "strike");
5562    }
5563
5564    #[test]
5565    fn inline_link() {
5566        let doc = markdown_to_adf("Click [here](https://example.com) now").unwrap();
5567        let content = doc.content[0].content.as_ref().unwrap();
5568        let link_node = &content[1];
5569        assert_eq!(link_node.text.as_deref(), Some("here"));
5570        let marks = link_node.marks.as_ref().unwrap();
5571        assert_eq!(marks[0].mark_type, "link");
5572    }
5573
5574    #[test]
5575    fn block_image() {
5576        let md = "![Alt text](https://example.com/image.png)";
5577        let doc = markdown_to_adf(md).unwrap();
5578        assert_eq!(doc.content[0].node_type, "mediaSingle");
5579    }
5580
5581    #[test]
5582    fn table() {
5583        let md = "| A | B |\n| --- | --- |\n| 1 | 2 |";
5584        let doc = markdown_to_adf(md).unwrap();
5585        assert_eq!(doc.content[0].node_type, "table");
5586        let rows = doc.content[0].content.as_ref().unwrap();
5587        assert_eq!(rows.len(), 2); // header + 1 body row
5588    }
5589
5590    // ── adf_to_markdown tests ───────────────────────────────────────
5591
5592    #[test]
5593    fn adf_paragraph_to_markdown() {
5594        let doc = AdfDocument {
5595            version: 1,
5596            doc_type: "doc".to_string(),
5597            content: vec![AdfNode::paragraph(vec![AdfNode::text("Hello world")])],
5598        };
5599        let md = adf_to_markdown(&doc).unwrap();
5600        assert_eq!(md.trim(), "Hello world");
5601    }
5602
5603    #[test]
5604    fn adf_heading_to_markdown() {
5605        let doc = AdfDocument {
5606            version: 1,
5607            doc_type: "doc".to_string(),
5608            content: vec![AdfNode::heading(2, vec![AdfNode::text("Title")])],
5609        };
5610        let md = adf_to_markdown(&doc).unwrap();
5611        assert_eq!(md.trim(), "## Title");
5612    }
5613
5614    #[test]
5615    fn adf_bold_to_markdown() {
5616        let doc = AdfDocument {
5617            version: 1,
5618            doc_type: "doc".to_string(),
5619            content: vec![AdfNode::paragraph(vec![
5620                AdfNode::text("Normal "),
5621                AdfNode::text_with_marks("bold", vec![AdfMark::strong()]),
5622                AdfNode::text(" text"),
5623            ])],
5624        };
5625        let md = adf_to_markdown(&doc).unwrap();
5626        assert_eq!(md.trim(), "Normal **bold** text");
5627    }
5628
5629    #[test]
5630    fn adf_code_block_to_markdown() {
5631        let doc = AdfDocument {
5632            version: 1,
5633            doc_type: "doc".to_string(),
5634            content: vec![AdfNode::code_block(Some("rust"), "let x = 1;")],
5635        };
5636        let md = adf_to_markdown(&doc).unwrap();
5637        assert!(md.contains("```rust"));
5638        assert!(md.contains("let x = 1;"));
5639        assert!(md.contains("```"));
5640    }
5641
5642    #[test]
5643    fn adf_rule_to_markdown() {
5644        let doc = AdfDocument {
5645            version: 1,
5646            doc_type: "doc".to_string(),
5647            content: vec![AdfNode::rule()],
5648        };
5649        let md = adf_to_markdown(&doc).unwrap();
5650        assert!(md.contains("---"));
5651    }
5652
5653    #[test]
5654    fn adf_bullet_list_to_markdown() {
5655        let doc = AdfDocument {
5656            version: 1,
5657            doc_type: "doc".to_string(),
5658            content: vec![AdfNode::bullet_list(vec![
5659                AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("A")])]),
5660                AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("B")])]),
5661            ])],
5662        };
5663        let md = adf_to_markdown(&doc).unwrap();
5664        assert!(md.contains("- A"));
5665        assert!(md.contains("- B"));
5666    }
5667
5668    #[test]
5669    fn adf_link_to_markdown() {
5670        let doc = AdfDocument {
5671            version: 1,
5672            doc_type: "doc".to_string(),
5673            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
5674                "click",
5675                vec![AdfMark::link("https://example.com")],
5676            )])],
5677        };
5678        let md = adf_to_markdown(&doc).unwrap();
5679        assert_eq!(md.trim(), "[click](https://example.com)");
5680    }
5681
5682    #[test]
5683    fn unsupported_block_preserved_as_json() {
5684        let doc = AdfDocument {
5685            version: 1,
5686            doc_type: "doc".to_string(),
5687            content: vec![AdfNode {
5688                node_type: "unknownBlock".to_string(),
5689                attrs: Some(serde_json::json!({"key": "value"})),
5690                content: None,
5691                text: None,
5692                marks: None,
5693                local_id: None,
5694                parameters: None,
5695            }],
5696        };
5697        let md = adf_to_markdown(&doc).unwrap();
5698        assert!(md.contains("```adf-unsupported"));
5699        assert!(md.contains("\"unknownBlock\""));
5700    }
5701
5702    #[test]
5703    fn unsupported_block_round_trips() {
5704        let original = AdfDocument {
5705            version: 1,
5706            doc_type: "doc".to_string(),
5707            content: vec![AdfNode {
5708                node_type: "unknownBlock".to_string(),
5709                attrs: Some(serde_json::json!({"key": "value"})),
5710                content: None,
5711                text: None,
5712                marks: None,
5713                local_id: None,
5714                parameters: None,
5715            }],
5716        };
5717        let md = adf_to_markdown(&original).unwrap();
5718        let restored = markdown_to_adf(&md).unwrap();
5719        assert_eq!(restored.content[0].node_type, "unknownBlock");
5720        assert_eq!(restored.content[0].attrs.as_ref().unwrap()["key"], "value");
5721    }
5722
5723    // ── Round-trip tests ────────────────────────────────────────────
5724
5725    #[test]
5726    fn round_trip_simple_document() {
5727        let md = "# Hello\n\nSome text with **bold** and *italic*.\n\n- Item 1\n- Item 2\n";
5728        let adf = markdown_to_adf(md).unwrap();
5729        let restored = adf_to_markdown(&adf).unwrap();
5730
5731        assert!(restored.contains("# Hello"));
5732        assert!(restored.contains("**bold**"));
5733        assert!(restored.contains("*italic*"));
5734        assert!(restored.contains("- Item 1"));
5735        assert!(restored.contains("- Item 2"));
5736    }
5737
5738    #[test]
5739    fn round_trip_code_block() {
5740        let md = "```python\nprint('hello')\n```\n";
5741        let adf = markdown_to_adf(md).unwrap();
5742        let restored = adf_to_markdown(&adf).unwrap();
5743
5744        assert!(restored.contains("```python"));
5745        assert!(restored.contains("print('hello')"));
5746    }
5747
5748    #[test]
5749    fn round_trip_code_block_no_attrs() {
5750        let adf_json = r#"{"version":1,"type":"doc","content":[
5751            {"type":"codeBlock","content":[{"type":"text","text":"plain code"}]}
5752        ]}"#;
5753        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
5754        assert!(doc.content[0].attrs.is_none());
5755        let md = adf_to_markdown(&doc).unwrap();
5756        let round_tripped = markdown_to_adf(&md).unwrap();
5757        assert!(round_tripped.content[0].attrs.is_none());
5758    }
5759
5760    #[test]
5761    fn round_trip_code_block_empty_language() {
5762        let adf_json = r#"{"version":1,"type":"doc","content":[
5763            {"type":"codeBlock","attrs":{"language":""},"content":[{"type":"text","text":"simple code block no backtick"}]}
5764        ]}"#;
5765        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
5766        let attrs = doc.content[0].attrs.as_ref().unwrap();
5767        assert_eq!(attrs["language"], "");
5768        let md = adf_to_markdown(&doc).unwrap();
5769        let round_tripped = markdown_to_adf(&md).unwrap();
5770        let rt_attrs = round_tripped.content[0].attrs.as_ref().unwrap();
5771        assert_eq!(rt_attrs["language"], "");
5772    }
5773
5774    #[test]
5775    fn round_trip_code_block_with_language() {
5776        let adf_json = r#"{"version":1,"type":"doc","content":[
5777            {"type":"codeBlock","attrs":{"language":"python"},"content":[{"type":"text","text":"print('hi')"}]}
5778        ]}"#;
5779        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
5780        let md = adf_to_markdown(&doc).unwrap();
5781        let round_tripped = markdown_to_adf(&md).unwrap();
5782        let rt_attrs = round_tripped.content[0].attrs.as_ref().unwrap();
5783        assert_eq!(rt_attrs["language"], "python");
5784    }
5785
5786    #[test]
5787    fn multiple_paragraphs() {
5788        let md = "First paragraph.\n\nSecond paragraph.\n";
5789        let adf = markdown_to_adf(md).unwrap();
5790        assert_eq!(adf.content.len(), 2);
5791        assert_eq!(adf.content[0].node_type, "paragraph");
5792        assert_eq!(adf.content[1].node_type, "paragraph");
5793    }
5794
5795    // ── Additional markdown_to_adf tests ───────────────────────────────
5796
5797    #[test]
5798    fn horizontal_rule_underscores() {
5799        let doc = markdown_to_adf("___").unwrap();
5800        assert_eq!(doc.content[0].node_type, "rule");
5801    }
5802
5803    #[test]
5804    fn not_a_horizontal_rule_too_short() {
5805        let doc = markdown_to_adf("--").unwrap();
5806        assert_eq!(doc.content[0].node_type, "paragraph");
5807    }
5808
5809    #[test]
5810    fn bullet_list_star_marker() {
5811        let md = "* Apple\n* Banana";
5812        let doc = markdown_to_adf(md).unwrap();
5813        assert_eq!(doc.content[0].node_type, "bulletList");
5814        let items = doc.content[0].content.as_ref().unwrap();
5815        assert_eq!(items.len(), 2);
5816    }
5817
5818    #[test]
5819    fn bullet_list_plus_marker() {
5820        let md = "+ One\n+ Two";
5821        let doc = markdown_to_adf(md).unwrap();
5822        assert_eq!(doc.content[0].node_type, "bulletList");
5823    }
5824
5825    #[test]
5826    fn ordered_list_non_one_start() {
5827        let md = "5. Fifth\n6. Sixth";
5828        let doc = markdown_to_adf(md).unwrap();
5829        let node = &doc.content[0];
5830        assert_eq!(node.node_type, "orderedList");
5831        let attrs = node.attrs.as_ref().unwrap();
5832        assert_eq!(attrs["order"], 5);
5833    }
5834
5835    #[test]
5836    fn ordered_list_start_at_one_has_order_attr() {
5837        let md = "1. First\n2. Second";
5838        let doc = markdown_to_adf(md).unwrap();
5839        let node = &doc.content[0];
5840        assert_eq!(node.node_type, "orderedList");
5841        assert_eq!(node.attrs.as_ref().unwrap()["order"], 1);
5842    }
5843
5844    #[test]
5845    fn blockquote_bare_marker() {
5846        // ">" with no space after
5847        let md = ">quoted text";
5848        let doc = markdown_to_adf(md).unwrap();
5849        assert_eq!(doc.content[0].node_type, "blockquote");
5850    }
5851
5852    #[test]
5853    fn image_no_alt() {
5854        let md = "![](https://example.com/img.png)";
5855        let doc = markdown_to_adf(md).unwrap();
5856        let node = &doc.content[0];
5857        assert_eq!(node.node_type, "mediaSingle");
5858        // media child should have no alt attr
5859        let media = &node.content.as_ref().unwrap()[0];
5860        let attrs = media.attrs.as_ref().unwrap();
5861        assert!(attrs.get("alt").is_none());
5862    }
5863
5864    #[test]
5865    fn image_with_alt() {
5866        let md = "![A photo](https://example.com/photo.jpg)";
5867        let doc = markdown_to_adf(md).unwrap();
5868        let media = &doc.content[0].content.as_ref().unwrap()[0];
5869        let attrs = media.attrs.as_ref().unwrap();
5870        assert_eq!(attrs["alt"], "A photo");
5871    }
5872
5873    #[test]
5874    fn table_multi_body_rows() {
5875        let md = "| H1 | H2 |\n| --- | --- |\n| a | b |\n| c | d |";
5876        let doc = markdown_to_adf(md).unwrap();
5877        let rows = doc.content[0].content.as_ref().unwrap();
5878        assert_eq!(rows.len(), 3); // header + 2 body rows
5879                                   // First row cells are tableHeader
5880        let header_cells = rows[0].content.as_ref().unwrap();
5881        assert_eq!(header_cells[0].node_type, "tableHeader");
5882        // Body row cells are tableCell
5883        let body_cells = rows[1].content.as_ref().unwrap();
5884        assert_eq!(body_cells[0].node_type, "tableCell");
5885    }
5886
5887    #[test]
5888    fn table_no_separator_is_not_table() {
5889        // Pipe characters without a separator row should not parse as table
5890        let md = "| not | a table |";
5891        let doc = markdown_to_adf(md).unwrap();
5892        assert_eq!(doc.content[0].node_type, "paragraph");
5893    }
5894
5895    #[test]
5896    fn inline_underscore_bold() {
5897        let doc = markdown_to_adf("Some __bold__ text").unwrap();
5898        let content = doc.content[0].content.as_ref().unwrap();
5899        let bold_node = &content[1];
5900        assert_eq!(bold_node.text.as_deref(), Some("bold"));
5901        let marks = bold_node.marks.as_ref().unwrap();
5902        assert_eq!(marks[0].mark_type, "strong");
5903    }
5904
5905    #[test]
5906    fn inline_underscore_italic() {
5907        let doc = markdown_to_adf("Some _italic_ text").unwrap();
5908        let content = doc.content[0].content.as_ref().unwrap();
5909        let italic_node = &content[1];
5910        assert_eq!(italic_node.text.as_deref(), Some("italic"));
5911        let marks = italic_node.marks.as_ref().unwrap();
5912        assert_eq!(marks[0].mark_type, "em");
5913    }
5914
5915    #[test]
5916    fn intraword_underscore_not_emphasis() {
5917        // Single intraword underscore pair: do_something_useful
5918        let doc = markdown_to_adf("call do_something_useful now").unwrap();
5919        let content = doc.content[0].content.as_ref().unwrap();
5920        assert_eq!(content.len(), 1, "should be a single text node");
5921        assert_eq!(
5922            content[0].text.as_deref(),
5923            Some("call do_something_useful now")
5924        );
5925        assert!(content[0].marks.is_none());
5926    }
5927
5928    #[test]
5929    fn intraword_underscore_multiple() {
5930        // Multiple intraword underscores: a_b_c_d
5931        let doc = markdown_to_adf("use a_b_c_d here").unwrap();
5932        let content = doc.content[0].content.as_ref().unwrap();
5933        assert_eq!(content.len(), 1);
5934        assert_eq!(content[0].text.as_deref(), Some("use a_b_c_d here"));
5935        assert!(content[0].marks.is_none());
5936    }
5937
5938    #[test]
5939    fn intraword_double_underscore_not_bold() {
5940        // Intraword double underscores: foo__bar__baz
5941        let doc = markdown_to_adf("foo__bar__baz").unwrap();
5942        let content = doc.content[0].content.as_ref().unwrap();
5943        assert_eq!(content.len(), 1);
5944        assert_eq!(content[0].text.as_deref(), Some("foo__bar__baz"));
5945        assert!(content[0].marks.is_none());
5946    }
5947
5948    #[test]
5949    fn intraword_triple_underscore_not_bold_italic() {
5950        // Intraword triple underscores: x___y___z
5951        let doc = markdown_to_adf("x___y___z").unwrap();
5952        let content = doc.content[0].content.as_ref().unwrap();
5953        assert_eq!(content.len(), 1);
5954        assert_eq!(content[0].text.as_deref(), Some("x___y___z"));
5955        assert!(content[0].marks.is_none());
5956    }
5957
5958    #[test]
5959    fn underscore_emphasis_still_works_with_spaces() {
5960        // Normal emphasis with spaces around delimiters should still work
5961        let doc = markdown_to_adf("some _italic_ here").unwrap();
5962        let content = doc.content[0].content.as_ref().unwrap();
5963        assert_eq!(content.len(), 3);
5964        assert_eq!(content[1].text.as_deref(), Some("italic"));
5965        let marks = content[1].marks.as_ref().unwrap();
5966        assert_eq!(marks[0].mark_type, "em");
5967    }
5968
5969    #[test]
5970    fn underscore_bold_still_works_with_spaces() {
5971        // Normal bold with spaces around delimiters should still work
5972        let doc = markdown_to_adf("some __bold__ here").unwrap();
5973        let content = doc.content[0].content.as_ref().unwrap();
5974        assert_eq!(content.len(), 3);
5975        assert_eq!(content[1].text.as_deref(), Some("bold"));
5976        let marks = content[1].marks.as_ref().unwrap();
5977        assert_eq!(marks[0].mark_type, "strong");
5978    }
5979
5980    #[test]
5981    fn intraword_underscore_closing_only() {
5982        // Opening _ is valid (preceded by space) but closing _ is intraword: _foo_bar
5983        let doc = markdown_to_adf("_foo_bar").unwrap();
5984        let content = doc.content[0].content.as_ref().unwrap();
5985        assert_eq!(content.len(), 1);
5986        assert_eq!(content[0].text.as_deref(), Some("_foo_bar"));
5987    }
5988
5989    #[test]
5990    fn intraword_double_underscore_closing_only() {
5991        // Opening __ is valid (at start) but closing __ is intraword: __foo__bar
5992        let doc = markdown_to_adf("__foo__bar").unwrap();
5993        let content = doc.content[0].content.as_ref().unwrap();
5994        assert_eq!(content.len(), 1);
5995        assert_eq!(content[0].text.as_deref(), Some("__foo__bar"));
5996    }
5997
5998    #[test]
5999    fn intraword_triple_underscore_closing_only() {
6000        // Opening ___ is valid (at start) but closing ___ is intraword: ___foo___bar
6001        let doc = markdown_to_adf("___foo___bar").unwrap();
6002        let content = doc.content[0].content.as_ref().unwrap();
6003        assert_eq!(content.len(), 1);
6004        assert_eq!(content[0].text.as_deref(), Some("___foo___bar"));
6005    }
6006
6007    #[test]
6008    fn asterisk_emphasis_unaffected_by_intraword_fix() {
6009        // Asterisks should still work for intraword emphasis (CommonMark allows this)
6010        let doc = markdown_to_adf("foo*bar*baz").unwrap();
6011        let content = doc.content[0].content.as_ref().unwrap();
6012        // Asterisks CAN be intraword emphasis per CommonMark
6013        assert!(content.len() > 1 || content[0].marks.is_some());
6014    }
6015
6016    #[test]
6017    fn intraword_underscore_at_start_of_text() {
6018        // Underscore at start of text is not intraword (no preceding alphanumeric)
6019        let doc = markdown_to_adf("_italic_ word").unwrap();
6020        let content = doc.content[0].content.as_ref().unwrap();
6021        assert_eq!(content[0].text.as_deref(), Some("italic"));
6022        let marks = content[0].marks.as_ref().unwrap();
6023        assert_eq!(marks[0].mark_type, "em");
6024    }
6025
6026    #[test]
6027    fn intraword_underscore_at_end_of_text() {
6028        // Underscore at end of text is not intraword (no following alphanumeric)
6029        let doc = markdown_to_adf("word _italic_").unwrap();
6030        let content = doc.content[0].content.as_ref().unwrap();
6031        let last = content.last().unwrap();
6032        assert_eq!(last.text.as_deref(), Some("italic"));
6033        let marks = last.marks.as_ref().unwrap();
6034        assert_eq!(marks[0].mark_type, "em");
6035    }
6036
6037    #[test]
6038    fn intraword_underscore_opening_only() {
6039        // Opening underscore is intraword but closing is not: a_b c_d
6040        // The first _ is intraword (a_b), so it shouldn't open emphasis
6041        let doc = markdown_to_adf("a_b c_d").unwrap();
6042        let content = doc.content[0].content.as_ref().unwrap();
6043        assert_eq!(content.len(), 1);
6044        assert_eq!(content[0].text.as_deref(), Some("a_b c_d"));
6045    }
6046
6047    #[test]
6048    fn intraword_underscore_roundtrip() {
6049        // The original reproducer from issue #438
6050        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"call the do_something_useful function"}]}]}"#;
6051        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
6052        let jfm = adf_to_markdown(&adf).unwrap();
6053        let roundtripped = markdown_to_adf(&jfm).unwrap();
6054        let content = roundtripped.content[0].content.as_ref().unwrap();
6055        assert_eq!(content.len(), 1, "should round-trip as a single text node");
6056        assert_eq!(
6057            content[0].text.as_deref(),
6058            Some("call the do_something_useful function")
6059        );
6060        assert!(content[0].marks.is_none());
6061    }
6062
6063    #[test]
6064    fn asterisk_emphasis_roundtrip() {
6065        // The original reproducer from issue #452
6066        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Status: *confirmed* and active"}]}]}"#;
6067        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
6068        let jfm = adf_to_markdown(&adf).unwrap();
6069        let roundtripped = markdown_to_adf(&jfm).unwrap();
6070        let content = roundtripped.content[0].content.as_ref().unwrap();
6071        assert_eq!(content.len(), 1, "should round-trip as a single text node");
6072        assert_eq!(
6073            content[0].text.as_deref(),
6074            Some("Status: *confirmed* and active")
6075        );
6076        assert!(content[0].marks.is_none());
6077    }
6078
6079    #[test]
6080    fn double_asterisk_roundtrip() {
6081        // **bold** delimiters in plain text should not become strong marks
6082        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use **kwargs in Python"}]}]}"#;
6083        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
6084        let jfm = adf_to_markdown(&adf).unwrap();
6085        let roundtripped = markdown_to_adf(&jfm).unwrap();
6086        let content = roundtripped.content[0].content.as_ref().unwrap();
6087        assert_eq!(content.len(), 1, "should round-trip as a single text node");
6088        assert_eq!(content[0].text.as_deref(), Some("Use **kwargs in Python"));
6089        assert!(content[0].marks.is_none());
6090    }
6091
6092    #[test]
6093    fn asterisk_with_em_mark_roundtrip() {
6094        // Text that already has an em mark should preserve both the mark and escaped content
6095        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a*b","marks":[{"type":"em"}]}]}]}"#;
6096        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
6097        let jfm = adf_to_markdown(&adf).unwrap();
6098        let roundtripped = markdown_to_adf(&jfm).unwrap();
6099        let content = roundtripped.content[0].content.as_ref().unwrap();
6100        // Find the node with em mark
6101        let em_node = content.iter().find(|n| {
6102            n.marks
6103                .as_ref()
6104                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "em"))
6105        });
6106        assert!(em_node.is_some(), "should have an em-marked node");
6107        assert_eq!(em_node.unwrap().text.as_deref(), Some("a*b"));
6108    }
6109
6110    #[test]
6111    fn lone_asterisk_roundtrip() {
6112        // A single asterisk that cannot form emphasis should round-trip
6113        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"rating: 5 * stars"}]}]}"#;
6114        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
6115        let jfm = adf_to_markdown(&adf).unwrap();
6116        let roundtripped = markdown_to_adf(&jfm).unwrap();
6117        let content = roundtripped.content[0].content.as_ref().unwrap();
6118        assert_eq!(content.len(), 1, "should round-trip as a single text node");
6119        assert_eq!(content[0].text.as_deref(), Some("rating: 5 * stars"));
6120    }
6121
6122    #[test]
6123    fn escape_emphasis_markers_unit() {
6124        assert_eq!(escape_emphasis_markers("hello"), "hello");
6125        assert_eq!(escape_emphasis_markers("*bold*"), r"\*bold\*");
6126        assert_eq!(escape_emphasis_markers("**strong**"), r"\*\*strong\*\*");
6127        assert_eq!(escape_emphasis_markers("no stars"), "no stars");
6128        assert_eq!(escape_emphasis_markers("a * b"), r"a \* b");
6129        assert_eq!(escape_emphasis_markers(""), "");
6130    }
6131
6132    #[test]
6133    fn find_unescaped_skips_backslash_escaped() {
6134        // Escaped `**` should not be found
6135        assert_eq!(find_unescaped(r"a\*\*b**", "**"), Some(6));
6136        // No unescaped match at all
6137        assert_eq!(find_unescaped(r"a\*\*b", "**"), None);
6138        // Plain match without any escaping
6139        assert_eq!(find_unescaped("a**b", "**"), Some(1));
6140        // Empty haystack
6141        assert_eq!(find_unescaped("", "**"), None);
6142    }
6143
6144    #[test]
6145    fn find_unescaped_char_skips_backslash_escaped() {
6146        // Escaped `*` should not be found
6147        assert_eq!(find_unescaped_char(r"a\*b*", b'*'), Some(4));
6148        // No unescaped match at all
6149        assert_eq!(find_unescaped_char(r"\*", b'*'), None);
6150        // Plain match
6151        assert_eq!(find_unescaped_char("a*b", b'*'), Some(1));
6152        // Empty haystack
6153        assert_eq!(find_unescaped_char("", b'*'), None);
6154    }
6155
6156    #[test]
6157    fn double_asterisk_in_strong_mark_roundtrip() {
6158        // Text with ** inside a strong mark should preserve the literal **
6159        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"call **kwargs","marks":[{"type":"strong"}]}]}]}"#;
6160        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
6161        let jfm = adf_to_markdown(&adf).unwrap();
6162        let roundtripped = markdown_to_adf(&jfm).unwrap();
6163        let content = roundtripped.content[0].content.as_ref().unwrap();
6164        let strong_node = content.iter().find(|n| {
6165            n.marks
6166                .as_ref()
6167                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
6168        });
6169        assert!(strong_node.is_some(), "should have a strong-marked node");
6170        assert_eq!(strong_node.unwrap().text.as_deref(), Some("call **kwargs"));
6171    }
6172
6173    #[test]
6174    fn backtick_code_roundtrip() {
6175        // The original reproducer from issue #453
6176        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Set `max_retries` to 3 in the config"}]}]}"#;
6177        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
6178        let jfm = adf_to_markdown(&adf).unwrap();
6179        let roundtripped = markdown_to_adf(&jfm).unwrap();
6180        let content = roundtripped.content[0].content.as_ref().unwrap();
6181        assert_eq!(content.len(), 1, "should round-trip as a single text node");
6182        assert_eq!(
6183            content[0].text.as_deref(),
6184            Some("Set `max_retries` to 3 in the config")
6185        );
6186        assert!(content[0].marks.is_none());
6187    }
6188
6189    #[test]
6190    fn multiple_backtick_spans_roundtrip() {
6191        // Multiple backtick-delimited spans in a single text node
6192        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use `foo` and `bar` together"}]}]}"#;
6193        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
6194        let jfm = adf_to_markdown(&adf).unwrap();
6195        let roundtripped = markdown_to_adf(&jfm).unwrap();
6196        let content = roundtripped.content[0].content.as_ref().unwrap();
6197        assert_eq!(content.len(), 1, "should round-trip as a single text node");
6198        assert_eq!(
6199            content[0].text.as_deref(),
6200            Some("Use `foo` and `bar` together")
6201        );
6202        assert!(content[0].marks.is_none());
6203    }
6204
6205    #[test]
6206    fn lone_backtick_roundtrip() {
6207        // A single backtick that cannot form a code span should round-trip
6208        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use a ` character"}]}]}"#;
6209        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
6210        let jfm = adf_to_markdown(&adf).unwrap();
6211        let roundtripped = markdown_to_adf(&jfm).unwrap();
6212        let content = roundtripped.content[0].content.as_ref().unwrap();
6213        assert_eq!(content.len(), 1, "should round-trip as a single text node");
6214        assert_eq!(content[0].text.as_deref(), Some("Use a ` character"));
6215        assert!(content[0].marks.is_none());
6216    }
6217
6218    #[test]
6219    fn backtick_with_code_mark_roundtrip() {
6220        // Text that already has a code mark should preserve both the mark and content
6221        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"max_retries","marks":[{"type":"code"}]}]}]}"#;
6222        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
6223        let jfm = adf_to_markdown(&adf).unwrap();
6224        assert_eq!(jfm.trim(), "`max_retries`");
6225        let roundtripped = markdown_to_adf(&jfm).unwrap();
6226        let content = roundtripped.content[0].content.as_ref().unwrap();
6227        let code_node = content.iter().find(|n| {
6228            n.marks
6229                .as_ref()
6230                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code"))
6231        });
6232        assert!(code_node.is_some(), "should have a code-marked node");
6233        assert_eq!(code_node.unwrap().text.as_deref(), Some("max_retries"));
6234    }
6235
6236    #[test]
6237    fn backtick_with_em_mark_roundtrip() {
6238        // Backtick inside em-marked text should preserve both
6239        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"use `cfg`","marks":[{"type":"em"}]}]}]}"#;
6240        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
6241        let jfm = adf_to_markdown(&adf).unwrap();
6242        let roundtripped = markdown_to_adf(&jfm).unwrap();
6243        let content = roundtripped.content[0].content.as_ref().unwrap();
6244        let em_node = content.iter().find(|n| {
6245            n.marks
6246                .as_ref()
6247                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "em"))
6248        });
6249        assert!(em_node.is_some(), "should have an em-marked node");
6250        assert_eq!(em_node.unwrap().text.as_deref(), Some("use `cfg`"));
6251    }
6252
6253    #[test]
6254    fn escape_backticks_unit() {
6255        assert_eq!(escape_backticks("hello"), "hello");
6256        assert_eq!(escape_backticks("`code`"), r"\`code\`");
6257        assert_eq!(escape_backticks("no ticks"), "no ticks");
6258        assert_eq!(escape_backticks("a ` b"), r"a \` b");
6259        assert_eq!(escape_backticks(""), "");
6260        assert_eq!(escape_backticks("`a` and `b`"), r"\`a\` and \`b\`");
6261    }
6262
6263    // ── Issue #477: backslash escaping ──────────────────────────────
6264
6265    #[test]
6266    fn backslash_in_text_roundtrip() {
6267        // The original reproducer from issue #477
6268        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"The path is C:\\Users\\admin\\file.txt"}]}]}"#;
6269        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
6270        let jfm = adf_to_markdown(&adf).unwrap();
6271        let roundtripped = markdown_to_adf(&jfm).unwrap();
6272        let content = roundtripped.content[0].content.as_ref().unwrap();
6273        assert_eq!(content.len(), 1, "should round-trip as a single text node");
6274        assert_eq!(
6275            content[0].text.as_deref(),
6276            Some(r"The path is C:\Users\admin\file.txt")
6277        );
6278    }
6279
6280    #[test]
6281    fn backslash_emitted_as_double_backslash() {
6282        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a\\b"}]}]}"#;
6283        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
6284        let jfm = adf_to_markdown(&adf).unwrap();
6285        assert!(
6286            jfm.contains(r"a\\b"),
6287            "JFM should contain escaped backslash: {jfm}"
6288        );
6289    }
6290
6291    #[test]
6292    fn consecutive_backslashes_roundtrip() {
6293        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a\\\\b"}]}]}"#;
6294        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
6295        let jfm = adf_to_markdown(&adf).unwrap();
6296        let roundtripped = markdown_to_adf(&jfm).unwrap();
6297        let content = roundtripped.content[0].content.as_ref().unwrap();
6298        assert_eq!(
6299            content[0].text.as_deref(),
6300            Some(r"a\\b"),
6301            "consecutive backslashes should survive round-trip"
6302        );
6303    }
6304
6305    #[test]
6306    fn backslash_with_strong_mark_roundtrip() {
6307        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\Users","marks":[{"type":"strong"}]}]}]}"#;
6308        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
6309        let jfm = adf_to_markdown(&adf).unwrap();
6310        let roundtripped = markdown_to_adf(&jfm).unwrap();
6311        let content = roundtripped.content[0].content.as_ref().unwrap();
6312        let strong_node = content.iter().find(|n| {
6313            n.marks
6314                .as_ref()
6315                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
6316        });
6317        assert!(strong_node.is_some(), "should have a strong-marked node");
6318        assert_eq!(strong_node.unwrap().text.as_deref(), Some(r"C:\Users"));
6319    }
6320
6321    #[test]
6322    fn backslash_with_code_mark_not_escaped() {
6323        // Code marks emit content verbatim — backslashes should NOT be escaped
6324        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\Users","marks":[{"type":"code"}]}]}]}"#;
6325        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
6326        let jfm = adf_to_markdown(&adf).unwrap();
6327        assert_eq!(jfm.trim(), r"`C:\Users`");
6328        let roundtripped = markdown_to_adf(&jfm).unwrap();
6329        let content = roundtripped.content[0].content.as_ref().unwrap();
6330        let code_node = content.iter().find(|n| {
6331            n.marks
6332                .as_ref()
6333                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code"))
6334        });
6335        assert!(code_node.is_some(), "should have a code-marked node");
6336        assert_eq!(code_node.unwrap().text.as_deref(), Some(r"C:\Users"));
6337    }
6338
6339    #[test]
6340    fn backslash_before_special_chars_roundtrip() {
6341        // Backslash before characters that are themselves escaped (* ` :)
6342        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"\\*not bold\\*"}]}]}"#;
6343        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
6344        let jfm = adf_to_markdown(&adf).unwrap();
6345        let roundtripped = markdown_to_adf(&jfm).unwrap();
6346        let content = roundtripped.content[0].content.as_ref().unwrap();
6347        assert_eq!(
6348            content[0].text.as_deref(),
6349            Some(r"\*not bold\*"),
6350            "backslash before special char should survive round-trip"
6351        );
6352    }
6353
6354    #[test]
6355    fn backslash_and_newline_in_text_roundtrip() {
6356        // Text with both backslashes and embedded newlines
6357        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\path\nline2"}]}]}"#;
6358        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
6359        let jfm = adf_to_markdown(&adf).unwrap();
6360        let roundtripped = markdown_to_adf(&jfm).unwrap();
6361        let content = roundtripped.content[0].content.as_ref().unwrap();
6362        assert_eq!(
6363            content[0].text.as_deref(),
6364            Some("C:\\path\nline2"),
6365            "backslash and newline should both survive round-trip"
6366        );
6367    }
6368
6369    #[test]
6370    fn lone_backslash_roundtrip() {
6371        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a \\ b"}]}]}"#;
6372        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
6373        let jfm = adf_to_markdown(&adf).unwrap();
6374        let roundtripped = markdown_to_adf(&jfm).unwrap();
6375        let content = roundtripped.content[0].content.as_ref().unwrap();
6376        assert_eq!(content[0].text.as_deref(), Some(r"a \ b"));
6377    }
6378
6379    #[test]
6380    fn trailing_backslash_in_text_roundtrip() {
6381        // A trailing backslash in text content (not a hardBreak) should round-trip
6382        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"end\\"}]}]}"#;
6383        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
6384        let jfm = adf_to_markdown(&adf).unwrap();
6385        let roundtripped = markdown_to_adf(&jfm).unwrap();
6386        let content = roundtripped.content[0].content.as_ref().unwrap();
6387        assert_eq!(
6388            content[0].text.as_deref(),
6389            Some(r"end\"),
6390            "trailing backslash should survive round-trip"
6391        );
6392    }
6393
6394    #[test]
6395    fn escape_bare_urls_unit() {
6396        assert_eq!(escape_bare_urls("hello"), "hello");
6397        assert_eq!(escape_bare_urls(""), "");
6398        assert_eq!(
6399            escape_bare_urls("https://example.com"),
6400            r"\https://example.com"
6401        );
6402        assert_eq!(
6403            escape_bare_urls("http://example.com"),
6404            r"\http://example.com"
6405        );
6406        assert_eq!(
6407            escape_bare_urls("see https://a.com and https://b.com"),
6408            r"see \https://a.com and \https://b.com"
6409        );
6410        // "http" without "://" is not a URL scheme — leave untouched
6411        assert_eq!(escape_bare_urls("http header"), "http header");
6412        assert_eq!(escape_bare_urls("https is secure"), "https is secure");
6413    }
6414
6415    #[test]
6416    fn heading_not_valid_without_space() {
6417        // "#Title" without space should be a paragraph, not heading
6418        let doc = markdown_to_adf("#Title").unwrap();
6419        assert_eq!(doc.content[0].node_type, "paragraph");
6420    }
6421
6422    #[test]
6423    fn heading_level_too_high() {
6424        // ####### (7 hashes) is not a valid heading
6425        let doc = markdown_to_adf("####### Not a heading").unwrap();
6426        assert_eq!(doc.content[0].node_type, "paragraph");
6427    }
6428
6429    #[test]
6430    fn empty_document() {
6431        let doc = markdown_to_adf("").unwrap();
6432        assert!(doc.content.is_empty());
6433    }
6434
6435    #[test]
6436    fn only_blank_lines() {
6437        let doc = markdown_to_adf("\n\n\n").unwrap();
6438        assert!(doc.content.is_empty());
6439    }
6440
6441    #[test]
6442    fn code_block_unterminated() {
6443        // Code block without closing fence
6444        let md = "```rust\nfn main() {}";
6445        let doc = markdown_to_adf(md).unwrap();
6446        assert_eq!(doc.content[0].node_type, "codeBlock");
6447    }
6448
6449    #[test]
6450    fn mixed_document() {
6451        let md = "# Title\n\nA paragraph.\n\n- Item\n\n```\ncode\n```\n\n> quote\n\n---\n\n1. numbered\n";
6452        let doc = markdown_to_adf(md).unwrap();
6453        let types: Vec<&str> = doc.content.iter().map(|n| n.node_type.as_str()).collect();
6454        assert_eq!(
6455            types,
6456            vec![
6457                "heading",
6458                "paragraph",
6459                "bulletList",
6460                "codeBlock",
6461                "blockquote",
6462                "rule",
6463                "orderedList",
6464            ]
6465        );
6466    }
6467
6468    // ── Additional adf_to_markdown tests ───────────────────────────────
6469
6470    #[test]
6471    fn adf_ordered_list_to_markdown() {
6472        let doc = AdfDocument {
6473            version: 1,
6474            doc_type: "doc".to_string(),
6475            content: vec![AdfNode::ordered_list(
6476                vec![
6477                    AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("First")])]),
6478                    AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("Second")])]),
6479                ],
6480                None,
6481            )],
6482        };
6483        let md = adf_to_markdown(&doc).unwrap();
6484        assert!(md.contains("1. First"));
6485        assert!(md.contains("2. Second"));
6486    }
6487
6488    #[test]
6489    fn adf_ordered_list_custom_start() {
6490        let doc = AdfDocument {
6491            version: 1,
6492            doc_type: "doc".to_string(),
6493            content: vec![AdfNode::ordered_list(
6494                vec![AdfNode::list_item(vec![AdfNode::paragraph(vec![
6495                    AdfNode::text("Third"),
6496                ])])],
6497                Some(3),
6498            )],
6499        };
6500        let md = adf_to_markdown(&doc).unwrap();
6501        assert!(md.contains("3. Third"));
6502    }
6503
6504    #[test]
6505    fn adf_blockquote_to_markdown() {
6506        let doc = AdfDocument {
6507            version: 1,
6508            doc_type: "doc".to_string(),
6509            content: vec![AdfNode::blockquote(vec![AdfNode::paragraph(vec![
6510                AdfNode::text("A quote"),
6511            ])])],
6512        };
6513        let md = adf_to_markdown(&doc).unwrap();
6514        assert!(md.contains("> A quote"));
6515    }
6516
6517    #[test]
6518    fn adf_table_to_markdown() {
6519        let doc = AdfDocument {
6520            version: 1,
6521            doc_type: "doc".to_string(),
6522            content: vec![AdfNode::table(vec![
6523                AdfNode::table_row(vec![
6524                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("Name")])]),
6525                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("Value")])]),
6526                ]),
6527                AdfNode::table_row(vec![
6528                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("a")])]),
6529                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("1")])]),
6530                ]),
6531            ])],
6532        };
6533        let md = adf_to_markdown(&doc).unwrap();
6534        assert!(md.contains("| Name | Value |"));
6535        assert!(md.contains("| --- | --- |"));
6536        assert!(md.contains("| a | 1 |"));
6537    }
6538
6539    #[test]
6540    fn adf_media_to_markdown() {
6541        let doc = AdfDocument {
6542            version: 1,
6543            doc_type: "doc".to_string(),
6544            content: vec![AdfNode::media_single(
6545                "https://example.com/img.png",
6546                Some("Alt"),
6547            )],
6548        };
6549        let md = adf_to_markdown(&doc).unwrap();
6550        assert!(md.contains("![Alt](https://example.com/img.png)"));
6551    }
6552
6553    #[test]
6554    fn adf_media_no_alt_to_markdown() {
6555        let doc = AdfDocument {
6556            version: 1,
6557            doc_type: "doc".to_string(),
6558            content: vec![AdfNode::media_single("https://example.com/img.png", None)],
6559        };
6560        let md = adf_to_markdown(&doc).unwrap();
6561        assert!(md.contains("![](https://example.com/img.png)"));
6562    }
6563
6564    #[test]
6565    fn adf_italic_to_markdown() {
6566        let doc = AdfDocument {
6567            version: 1,
6568            doc_type: "doc".to_string(),
6569            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6570                "emphasis",
6571                vec![AdfMark::em()],
6572            )])],
6573        };
6574        let md = adf_to_markdown(&doc).unwrap();
6575        assert_eq!(md.trim(), "*emphasis*");
6576    }
6577
6578    #[test]
6579    fn adf_strikethrough_to_markdown() {
6580        let doc = AdfDocument {
6581            version: 1,
6582            doc_type: "doc".to_string(),
6583            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6584                "deleted",
6585                vec![AdfMark::strike()],
6586            )])],
6587        };
6588        let md = adf_to_markdown(&doc).unwrap();
6589        assert_eq!(md.trim(), "~~deleted~~");
6590    }
6591
6592    #[test]
6593    fn adf_inline_code_to_markdown() {
6594        let doc = AdfDocument {
6595            version: 1,
6596            doc_type: "doc".to_string(),
6597            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6598                "code",
6599                vec![AdfMark::code()],
6600            )])],
6601        };
6602        let md = adf_to_markdown(&doc).unwrap();
6603        assert_eq!(md.trim(), "`code`");
6604    }
6605
6606    #[test]
6607    fn adf_code_with_link_to_markdown() {
6608        let doc = AdfDocument {
6609            version: 1,
6610            doc_type: "doc".to_string(),
6611            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6612                "func",
6613                vec![AdfMark::code(), AdfMark::link("https://example.com")],
6614            )])],
6615        };
6616        let md = adf_to_markdown(&doc).unwrap();
6617        assert_eq!(md.trim(), "[`func`](https://example.com)");
6618    }
6619
6620    #[test]
6621    fn adf_bold_italic_to_markdown() {
6622        let doc = AdfDocument {
6623            version: 1,
6624            doc_type: "doc".to_string(),
6625            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6626                "both",
6627                vec![AdfMark::strong(), AdfMark::em()],
6628            )])],
6629        };
6630        let md = adf_to_markdown(&doc).unwrap();
6631        assert_eq!(md.trim(), "***both***");
6632    }
6633
6634    #[test]
6635    fn adf_bold_link_to_markdown() {
6636        let doc = AdfDocument {
6637            version: 1,
6638            doc_type: "doc".to_string(),
6639            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6640                "bold link",
6641                vec![AdfMark::strong(), AdfMark::link("https://example.com")],
6642            )])],
6643        };
6644        let md = adf_to_markdown(&doc).unwrap();
6645        assert_eq!(md.trim(), "**[bold link](https://example.com)**");
6646    }
6647
6648    #[test]
6649    fn adf_strikethrough_bold_to_markdown() {
6650        let doc = AdfDocument {
6651            version: 1,
6652            doc_type: "doc".to_string(),
6653            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6654                "struck",
6655                vec![AdfMark::strike(), AdfMark::strong()],
6656            )])],
6657        };
6658        let md = adf_to_markdown(&doc).unwrap();
6659        assert_eq!(md.trim(), "~~**struck**~~");
6660    }
6661
6662    #[test]
6663    fn adf_hard_break_to_markdown() {
6664        let doc = AdfDocument {
6665            version: 1,
6666            doc_type: "doc".to_string(),
6667            content: vec![AdfNode::paragraph(vec![
6668                AdfNode::text("Line 1"),
6669                AdfNode::hard_break(),
6670                AdfNode::text("Line 2"),
6671            ])],
6672        };
6673        let md = adf_to_markdown(&doc).unwrap();
6674        assert!(md.contains("Line 1\\\n  Line 2"));
6675    }
6676
6677    #[test]
6678    #[test]
6679    fn adf_unsupported_inline_to_markdown() {
6680        let doc = AdfDocument {
6681            version: 1,
6682            doc_type: "doc".to_string(),
6683            content: vec![AdfNode::paragraph(vec![AdfNode {
6684                node_type: "unknownInline".to_string(),
6685                attrs: None,
6686                content: None,
6687                text: None,
6688                marks: None,
6689                local_id: None,
6690                parameters: None,
6691            }])],
6692        };
6693        let md = adf_to_markdown(&doc).unwrap();
6694        assert!(md.contains("<!-- unsupported inline: unknownInline -->"));
6695    }
6696
6697    // ── mediaInline tests (issue #476) ─────────────────────────────────
6698
6699    #[test]
6700    fn adf_media_inline_to_markdown() {
6701        let doc = AdfDocument {
6702            version: 1,
6703            doc_type: "doc".to_string(),
6704            content: vec![AdfNode::paragraph(vec![
6705                AdfNode::text("see "),
6706                AdfNode::media_inline(serde_json::json!({
6707                    "type": "image",
6708                    "id": "abcdef01-2345-6789-abcd-abcdef012345",
6709                    "collection": "contentId-111111",
6710                    "width": 200,
6711                    "height": 100
6712                })),
6713                AdfNode::text(" for details"),
6714            ])],
6715        };
6716        let md = adf_to_markdown(&doc).unwrap();
6717        assert!(md.contains(":media-inline[]{"), "got: {md}");
6718        assert!(md.contains("type=image"));
6719        assert!(md.contains("id=abcdef01-2345-6789-abcd-abcdef012345"));
6720        assert!(md.contains("collection=contentId-111111"));
6721        assert!(md.contains("width=200"));
6722        assert!(md.contains("height=100"));
6723        assert!(!md.contains("<!-- unsupported inline"));
6724    }
6725
6726    #[test]
6727    fn media_inline_round_trip() {
6728        let doc = AdfDocument {
6729            version: 1,
6730            doc_type: "doc".to_string(),
6731            content: vec![AdfNode::paragraph(vec![
6732                AdfNode::text("see "),
6733                AdfNode::media_inline(serde_json::json!({
6734                    "type": "image",
6735                    "id": "abcdef01-2345-6789-abcd-abcdef012345",
6736                    "collection": "contentId-111111",
6737                    "width": 200,
6738                    "height": 100
6739                })),
6740                AdfNode::text(" for details"),
6741            ])],
6742        };
6743        let md = adf_to_markdown(&doc).unwrap();
6744        let rt = markdown_to_adf(&md).unwrap();
6745
6746        let content = rt.content[0].content.as_ref().unwrap();
6747        assert_eq!(content[0].text.as_deref(), Some("see "));
6748        assert_eq!(content[1].node_type, "mediaInline");
6749        let attrs = content[1].attrs.as_ref().unwrap();
6750        assert_eq!(attrs["type"], "image");
6751        assert_eq!(attrs["id"], "abcdef01-2345-6789-abcd-abcdef012345");
6752        assert_eq!(attrs["collection"], "contentId-111111");
6753        assert_eq!(attrs["width"], 200);
6754        assert_eq!(attrs["height"], 100);
6755        assert_eq!(content[2].text.as_deref(), Some(" for details"));
6756    }
6757
6758    #[test]
6759    fn media_inline_external_url_round_trip() {
6760        let doc = AdfDocument {
6761            version: 1,
6762            doc_type: "doc".to_string(),
6763            content: vec![AdfNode::paragraph(vec![AdfNode::media_inline(
6764                serde_json::json!({
6765                    "type": "external",
6766                    "url": "https://example.com/image.png",
6767                    "alt": "example",
6768                    "width": 400,
6769                    "height": 300
6770                }),
6771            )])],
6772        };
6773        let md = adf_to_markdown(&doc).unwrap();
6774        let rt = markdown_to_adf(&md).unwrap();
6775
6776        let content = rt.content[0].content.as_ref().unwrap();
6777        assert_eq!(content[0].node_type, "mediaInline");
6778        let attrs = content[0].attrs.as_ref().unwrap();
6779        assert_eq!(attrs["type"], "external");
6780        assert_eq!(attrs["url"], "https://example.com/image.png");
6781        assert_eq!(attrs["alt"], "example");
6782        assert_eq!(attrs["width"], 400);
6783        assert_eq!(attrs["height"], 300);
6784    }
6785
6786    #[test]
6787    fn media_inline_minimal_attrs() {
6788        let doc = AdfDocument {
6789            version: 1,
6790            doc_type: "doc".to_string(),
6791            content: vec![AdfNode::paragraph(vec![AdfNode::media_inline(
6792                serde_json::json!({"type": "file", "id": "abc-123"}),
6793            )])],
6794        };
6795        let md = adf_to_markdown(&doc).unwrap();
6796        let rt = markdown_to_adf(&md).unwrap();
6797
6798        let content = rt.content[0].content.as_ref().unwrap();
6799        assert_eq!(content[0].node_type, "mediaInline");
6800        let attrs = content[0].attrs.as_ref().unwrap();
6801        assert_eq!(attrs["type"], "file");
6802        assert_eq!(attrs["id"], "abc-123");
6803    }
6804
6805    #[test]
6806    fn media_inline_from_issue_476_reproducer() {
6807        // Exact reproducer from issue #476
6808        let adf_json: serde_json::Value = serde_json::json!({
6809            "type": "doc",
6810            "version": 1,
6811            "content": [
6812                {
6813                    "type": "paragraph",
6814                    "content": [
6815                        {"type": "text", "text": "see "},
6816                        {
6817                            "type": "mediaInline",
6818                            "attrs": {
6819                                "collection": "contentId-111111",
6820                                "height": 100,
6821                                "id": "abcdef01-2345-6789-abcd-abcdef012345",
6822                                "localId": "aabbccdd-1234-5678-abcd-aabbccdd1234",
6823                                "type": "image",
6824                                "width": 200
6825                            }
6826                        },
6827                        {"type": "text", "text": " for details"}
6828                    ]
6829                }
6830            ]
6831        });
6832        let doc: AdfDocument = serde_json::from_value(adf_json).unwrap();
6833        let md = adf_to_markdown(&doc).unwrap();
6834        assert!(
6835            !md.contains("<!-- unsupported inline"),
6836            "mediaInline should not be unsupported; got: {md}"
6837        );
6838
6839        let rt = markdown_to_adf(&md).unwrap();
6840        let content = rt.content[0].content.as_ref().unwrap();
6841        assert_eq!(content[1].node_type, "mediaInline");
6842        let attrs = content[1].attrs.as_ref().unwrap();
6843        assert_eq!(attrs["type"], "image");
6844        assert_eq!(attrs["id"], "abcdef01-2345-6789-abcd-abcdef012345");
6845        assert_eq!(attrs["collection"], "contentId-111111");
6846        assert_eq!(attrs["width"], 200);
6847        assert_eq!(attrs["height"], 100);
6848        assert_eq!(attrs["localId"], "aabbccdd-1234-5678-abcd-aabbccdd1234");
6849    }
6850
6851    #[test]
6852    fn emoji_shortcode() {
6853        let doc = markdown_to_adf("Hello :wave: world").unwrap();
6854        let content = doc.content[0].content.as_ref().unwrap();
6855        assert_eq!(content[0].text.as_deref(), Some("Hello "));
6856        assert_eq!(content[1].node_type, "emoji");
6857        assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":wave:");
6858        assert_eq!(content[2].text.as_deref(), Some(" world"));
6859    }
6860
6861    #[test]
6862    fn adf_emoji_to_markdown() {
6863        let doc = AdfDocument {
6864            version: 1,
6865            doc_type: "doc".to_string(),
6866            content: vec![AdfNode::paragraph(vec![AdfNode::emoji("thumbsup")])],
6867        };
6868        let md = adf_to_markdown(&doc).unwrap();
6869        assert!(md.contains(":thumbsup:"));
6870    }
6871
6872    #[test]
6873    fn adf_emoji_with_colon_prefix_to_markdown() {
6874        // JIRA stores shortName as ":thumbsup:" with colons
6875        let doc = AdfDocument {
6876            version: 1,
6877            doc_type: "doc".to_string(),
6878            content: vec![AdfNode::paragraph(vec![AdfNode {
6879                node_type: "emoji".to_string(),
6880                attrs: Some(serde_json::json!({"shortName": ":thumbsup:"})),
6881                content: None,
6882                text: None,
6883                marks: None,
6884                local_id: None,
6885                parameters: None,
6886            }])],
6887        };
6888        let md = adf_to_markdown(&doc).unwrap();
6889        assert!(md.contains(":thumbsup:"));
6890        // Should not produce ::thumbsup:: (double colons)
6891        assert!(!md.contains("::thumbsup::"));
6892    }
6893
6894    #[test]
6895    fn round_trip_emoji() {
6896        let md = "Hello :wave: world\n";
6897        let doc = markdown_to_adf(md).unwrap();
6898        let result = adf_to_markdown(&doc).unwrap();
6899        assert!(result.contains(":wave:"));
6900    }
6901
6902    #[test]
6903    fn emoji_with_id_and_text_round_trips() {
6904        let doc = AdfDocument {
6905            version: 1,
6906            doc_type: "doc".to_string(),
6907            content: vec![AdfNode::paragraph(vec![AdfNode {
6908                node_type: "emoji".to_string(),
6909                attrs: Some(
6910                    serde_json::json!({"shortName": ":check_mark:", "id": "2705", "text": "✅"}),
6911                ),
6912                content: None,
6913                text: None,
6914                marks: None,
6915                local_id: None,
6916                parameters: None,
6917            }])],
6918        };
6919        let md = adf_to_markdown(&doc).unwrap();
6920        assert!(md.contains(":check_mark:"), "shortcode present: {md}");
6921        assert!(md.contains("id="), "id attr present: {md}");
6922        assert!(md.contains("text="), "text attr present: {md}");
6923
6924        // Round-trip back to ADF
6925        let round_tripped = markdown_to_adf(&md).unwrap();
6926        let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
6927        let attrs = emoji.attrs.as_ref().unwrap();
6928        assert_eq!(attrs["shortName"], ":check_mark:");
6929        assert_eq!(attrs["id"], "2705");
6930        assert_eq!(attrs["text"], "✅");
6931    }
6932
6933    #[test]
6934    fn emoji_without_extra_attrs_still_works() {
6935        let md = "Hello :wave: world\n";
6936        let doc = markdown_to_adf(md).unwrap();
6937        let emoji = &doc.content[0].content.as_ref().unwrap()[1];
6938        assert_eq!(emoji.attrs.as_ref().unwrap()["shortName"], ":wave:");
6939        // No id or text attrs when not provided
6940        assert!(emoji.attrs.as_ref().unwrap().get("id").is_none());
6941    }
6942
6943    #[test]
6944    fn emoji_shortname_preserves_colons_round_trip() {
6945        // Issue #362: emoji shortName colons stripped during round-trip
6946        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
6947          {"type":"emoji","attrs":{"shortName":":cross_mark:","id":"atlassian-cross_mark","text":"❌"}}
6948        ]}]}"#;
6949        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
6950
6951        // ADF → markdown → ADF round-trip
6952        let md = adf_to_markdown(&doc).unwrap();
6953        let round_tripped = markdown_to_adf(&md).unwrap();
6954        let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
6955        let attrs = emoji.attrs.as_ref().unwrap();
6956        assert_eq!(
6957            attrs["shortName"], ":cross_mark:",
6958            "shortName should preserve colons, got: {}",
6959            attrs["shortName"]
6960        );
6961        assert_eq!(attrs["id"], "atlassian-cross_mark");
6962        assert_eq!(attrs["text"], "❌");
6963    }
6964
6965    #[test]
6966    fn emoji_shortname_without_colons_preserved() {
6967        // Issue #379: shortName without colons should not gain colons
6968        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
6969          {"type":"emoji","attrs":{"shortName":"white_check_mark","id":"2705","text":"✅"}}
6970        ]}]}"#;
6971        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
6972        let md = adf_to_markdown(&doc).unwrap();
6973        let round_tripped = markdown_to_adf(&md).unwrap();
6974        let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
6975        let attrs = emoji.attrs.as_ref().unwrap();
6976        assert_eq!(
6977            attrs["shortName"], "white_check_mark",
6978            "shortName without colons should stay without colons, got: {}",
6979            attrs["shortName"]
6980        );
6981    }
6982
6983    #[test]
6984    fn colon_in_text_not_emoji() {
6985        // A lone colon should not trigger emoji parsing
6986        let doc = markdown_to_adf("Time is 10:30 today").unwrap();
6987        let content = doc.content[0].content.as_ref().unwrap();
6988        assert_eq!(content.len(), 1);
6989        assert_eq!(content[0].node_type, "text");
6990    }
6991
6992    #[test]
6993    fn text_with_shortcode_pattern_round_trips_as_text() {
6994        // Issue #450: `:fire:` in a text node must not become an emoji node
6995        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Alert :fire: triggered on pod:pod42"}]}]}"#;
6996        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
6997
6998        let md = adf_to_markdown(&doc).unwrap();
6999        let round_tripped = markdown_to_adf(&md).unwrap();
7000        let content = round_tripped.content[0].content.as_ref().unwrap();
7001
7002        assert_eq!(
7003            content.len(),
7004            1,
7005            "should be a single text node, got: {content:?}"
7006        );
7007        assert_eq!(content[0].node_type, "text");
7008        assert_eq!(
7009            content[0].text.as_deref().unwrap(),
7010            "Alert :fire: triggered on pod:pod42"
7011        );
7012    }
7013
7014    #[test]
7015    fn double_colon_pattern_round_trips_as_text() {
7016        // Issue #450: `::Active::` should not be parsed as emoji `:Active:`
7017        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Status::Active::Running"}]}]}"#;
7018        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
7019
7020        let md = adf_to_markdown(&doc).unwrap();
7021        let round_tripped = markdown_to_adf(&md).unwrap();
7022        let content = round_tripped.content[0].content.as_ref().unwrap();
7023
7024        assert_eq!(
7025            content.len(),
7026            1,
7027            "should be a single text node, got: {content:?}"
7028        );
7029        assert_eq!(content[0].node_type, "text");
7030        assert_eq!(
7031            content[0].text.as_deref().unwrap(),
7032            "Status::Active::Running"
7033        );
7034    }
7035
7036    #[test]
7037    fn real_emoji_node_still_round_trips() {
7038        // Ensure actual emoji ADF nodes still work after the escaping fix
7039        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
7040          {"type":"text","text":"Hello "},
7041          {"type":"emoji","attrs":{"shortName":":fire:","id":"1f525","text":"🔥"}},
7042          {"type":"text","text":" world"}
7043        ]}]}"#;
7044        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
7045
7046        let md = adf_to_markdown(&doc).unwrap();
7047        let round_tripped = markdown_to_adf(&md).unwrap();
7048        let content = round_tripped.content[0].content.as_ref().unwrap();
7049
7050        // Should have: text("Hello ") + emoji(:fire:) + text(" world")
7051        assert_eq!(content.len(), 3, "should have 3 nodes: {content:?}");
7052        assert_eq!(content[0].text.as_deref(), Some("Hello "));
7053        assert_eq!(content[1].node_type, "emoji");
7054        assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":fire:");
7055        assert_eq!(content[2].text.as_deref(), Some(" world"));
7056    }
7057
7058    #[test]
7059    fn text_shortcode_with_marks_round_trips() {
7060        // Bold text containing an emoji-like shortcode should round-trip as bold text
7061        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
7062          {"type":"text","text":"Alert :fire: triggered","marks":[{"type":"strong"}]}
7063        ]}]}"#;
7064        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
7065
7066        let md = adf_to_markdown(&doc).unwrap();
7067        let round_tripped = markdown_to_adf(&md).unwrap();
7068        let content = round_tripped.content[0].content.as_ref().unwrap();
7069
7070        assert_eq!(
7071            content.len(),
7072            1,
7073            "should be single bold text node: {content:?}"
7074        );
7075        assert_eq!(content[0].node_type, "text");
7076        assert_eq!(
7077            content[0].text.as_deref().unwrap(),
7078            "Alert :fire: triggered"
7079        );
7080        assert!(content[0]
7081            .marks
7082            .as_ref()
7083            .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong")));
7084    }
7085
7086    #[test]
7087    fn mixed_emoji_node_and_text_shortcode_round_trips() {
7088        // Real emoji node adjacent to text containing a shortcode-like pattern
7089        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
7090          {"type":"emoji","attrs":{"shortName":":wave:","id":"1f44b","text":"👋"}},
7091          {"type":"text","text":" says :hello: to you"}
7092        ]}]}"#;
7093        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
7094
7095        let md = adf_to_markdown(&doc).unwrap();
7096        let round_tripped = markdown_to_adf(&md).unwrap();
7097        let content = round_tripped.content[0].content.as_ref().unwrap();
7098
7099        // Should be: emoji(:wave:) + text(" says :hello: to you")
7100        assert_eq!(content.len(), 2, "should have 2 nodes: {content:?}");
7101        assert_eq!(content[0].node_type, "emoji");
7102        assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":wave:");
7103        assert_eq!(content[1].node_type, "text");
7104        assert_eq!(content[1].text.as_deref().unwrap(), " says :hello: to you");
7105    }
7106
7107    #[test]
7108    fn adf_inline_card_to_markdown() {
7109        let doc = AdfDocument {
7110            version: 1,
7111            doc_type: "doc".to_string(),
7112            content: vec![AdfNode::paragraph(vec![AdfNode {
7113                node_type: "inlineCard".to_string(),
7114                attrs: Some(
7115                    serde_json::json!({"url": "https://org.atlassian.net/browse/ACCS-4382"}),
7116                ),
7117                content: None,
7118                text: None,
7119                marks: None,
7120                local_id: None,
7121                parameters: None,
7122            }])],
7123        };
7124        let md = adf_to_markdown(&doc).unwrap();
7125        assert!(md.contains(":card[https://org.atlassian.net/browse/ACCS-4382]"));
7126        assert!(!md.contains("<!-- unsupported inline"));
7127    }
7128
7129    #[test]
7130    fn inline_card_directive_round_trips() {
7131        // inlineCard → :card[url] → inlineCard
7132        let original = AdfDocument {
7133            version: 1,
7134            doc_type: "doc".to_string(),
7135            content: vec![AdfNode::paragraph(vec![AdfNode::inline_card(
7136                "https://org.atlassian.net/browse/ACCS-4382",
7137            )])],
7138        };
7139        let md = adf_to_markdown(&original).unwrap();
7140        assert!(md.contains(":card[https://org.atlassian.net/browse/ACCS-4382]"));
7141        let restored = markdown_to_adf(&md).unwrap();
7142        let node = &restored.content[0].content.as_ref().unwrap()[0];
7143        assert_eq!(node.node_type, "inlineCard");
7144        assert_eq!(
7145            node.attrs.as_ref().unwrap()["url"],
7146            "https://org.atlassian.net/browse/ACCS-4382"
7147        );
7148    }
7149
7150    #[test]
7151    fn inline_card_directive_parsed_from_jfm() {
7152        // :card[url] in JFM → inlineCard in ADF
7153        let doc = markdown_to_adf("See :card[https://example.com/issue/123] for details.").unwrap();
7154        let nodes = doc.content[0].content.as_ref().unwrap();
7155        assert_eq!(nodes[0].node_type, "text");
7156        assert_eq!(nodes[0].text.as_deref(), Some("See "));
7157        assert_eq!(nodes[1].node_type, "inlineCard");
7158        assert_eq!(
7159            nodes[1].attrs.as_ref().unwrap()["url"],
7160            "https://example.com/issue/123"
7161        );
7162        assert_eq!(nodes[2].node_type, "text");
7163        assert_eq!(nodes[2].text.as_deref(), Some(" for details."));
7164    }
7165
7166    #[test]
7167    fn self_link_becomes_link_mark_not_inline_card() {
7168        // Issue #378: [url](url) should produce a link mark, not inlineCard.
7169        // inlineCard is only for :card[url] directives and bare URLs.
7170        let doc = markdown_to_adf("[https://example.com](https://example.com)").unwrap();
7171        let node = &doc.content[0].content.as_ref().unwrap()[0];
7172        assert_eq!(node.node_type, "text");
7173        assert_eq!(node.text.as_deref(), Some("https://example.com"));
7174        let mark = &node.marks.as_ref().unwrap()[0];
7175        assert_eq!(mark.mark_type, "link");
7176        assert_eq!(mark.attrs.as_ref().unwrap()["href"], "https://example.com");
7177    }
7178
7179    #[test]
7180    fn url_link_text_with_trailing_slash_mismatch_becomes_link_mark() {
7181        // Issue #523: [url](url/) where text and href differ only by trailing
7182        // slash should produce a text node with link mark, not an inlineCard.
7183        let doc =
7184            markdown_to_adf("[https://octopz.example.com](https://octopz.example.com/)").unwrap();
7185        let node = &doc.content[0].content.as_ref().unwrap()[0];
7186        assert_eq!(node.node_type, "text");
7187        assert_eq!(node.text.as_deref(), Some("https://octopz.example.com"));
7188        let mark = &node.marks.as_ref().unwrap()[0];
7189        assert_eq!(mark.mark_type, "link");
7190        assert_eq!(
7191            mark.attrs.as_ref().unwrap()["href"],
7192            "https://octopz.example.com/"
7193        );
7194    }
7195
7196    #[test]
7197    fn named_link_does_not_become_inline_card() {
7198        // [#4668](url) — text differs from url, stays as a link mark
7199        let doc = markdown_to_adf("[#4668](https://github.com/org/repo/pull/4668)").unwrap();
7200        let node = &doc.content[0].content.as_ref().unwrap()[0];
7201        assert_eq!(node.node_type, "text");
7202        assert_eq!(node.text.as_deref(), Some("#4668"));
7203        let mark = &node.marks.as_ref().unwrap()[0];
7204        assert_eq!(mark.mark_type, "link");
7205    }
7206
7207    #[test]
7208    fn adf_inline_card_no_url_to_markdown() {
7209        let doc = AdfDocument {
7210            version: 1,
7211            doc_type: "doc".to_string(),
7212            content: vec![AdfNode::paragraph(vec![AdfNode {
7213                node_type: "inlineCard".to_string(),
7214                attrs: Some(serde_json::json!({})),
7215                content: None,
7216                text: None,
7217                marks: None,
7218                local_id: None,
7219                parameters: None,
7220            }])],
7221        };
7222        let md = adf_to_markdown(&doc).unwrap();
7223        // No url attr — renders nothing (not a comment)
7224        assert!(!md.contains("<!-- unsupported inline"));
7225    }
7226
7227    #[test]
7228    fn adf_code_block_no_language_to_markdown() {
7229        let doc = AdfDocument {
7230            version: 1,
7231            doc_type: "doc".to_string(),
7232            content: vec![AdfNode::code_block(None, "plain code")],
7233        };
7234        let md = adf_to_markdown(&doc).unwrap();
7235        assert!(md.contains("```\n"));
7236        assert!(md.contains("plain code"));
7237    }
7238
7239    #[test]
7240    fn adf_code_block_empty_language_to_markdown() {
7241        let doc = AdfDocument {
7242            version: 1,
7243            doc_type: "doc".to_string(),
7244            content: vec![AdfNode::code_block(Some(""), "plain code")],
7245        };
7246        let md = adf_to_markdown(&doc).unwrap();
7247        assert!(md.contains("```\"\"\n"));
7248        assert!(md.contains("plain code"));
7249    }
7250
7251    // ── Additional round-trip tests ────────────────────────────────────
7252
7253    #[test]
7254    fn round_trip_table() {
7255        let md = "| A | B |\n| --- | --- |\n| 1 | 2 |\n";
7256        let adf = markdown_to_adf(md).unwrap();
7257        let restored = adf_to_markdown(&adf).unwrap();
7258        assert!(restored.contains("| A | B |"));
7259        assert!(restored.contains("| 1 | 2 |"));
7260    }
7261
7262    #[test]
7263    fn round_trip_blockquote() {
7264        let md = "> This is quoted\n";
7265        let adf = markdown_to_adf(md).unwrap();
7266        let restored = adf_to_markdown(&adf).unwrap();
7267        assert!(restored.contains("> This is quoted"));
7268    }
7269
7270    #[test]
7271    fn round_trip_image() {
7272        let md = "![Logo](https://example.com/logo.png)\n";
7273        let adf = markdown_to_adf(md).unwrap();
7274        let restored = adf_to_markdown(&adf).unwrap();
7275        assert!(restored.contains("![Logo](https://example.com/logo.png)"));
7276    }
7277
7278    #[test]
7279    fn round_trip_ordered_list() {
7280        let md = "1. A\n2. B\n3. C\n";
7281        let adf = markdown_to_adf(md).unwrap();
7282        let restored = adf_to_markdown(&adf).unwrap();
7283        assert!(restored.contains("1. A"));
7284        assert!(restored.contains("2. B"));
7285        assert!(restored.contains("3. C"));
7286    }
7287
7288    #[test]
7289    fn round_trip_inline_marks() {
7290        let md = "Text with `code` and ~~strike~~ and [link](https://x.com).\n";
7291        let adf = markdown_to_adf(md).unwrap();
7292        let restored = adf_to_markdown(&adf).unwrap();
7293        assert!(restored.contains("`code`"));
7294        assert!(restored.contains("~~strike~~"));
7295        assert!(restored.contains("[link](https://x.com)"));
7296    }
7297
7298    // ── Container directive tests (Tier 2) ───────────────────────────
7299
7300    #[test]
7301    fn panel_info() {
7302        let md = ":::panel{type=info}\nThis is informational.\n:::";
7303        let doc = markdown_to_adf(md).unwrap();
7304        assert_eq!(doc.content[0].node_type, "panel");
7305        assert_eq!(doc.content[0].attrs.as_ref().unwrap()["panelType"], "info");
7306        let inner = doc.content[0].content.as_ref().unwrap();
7307        assert_eq!(inner[0].node_type, "paragraph");
7308    }
7309
7310    #[test]
7311    fn adf_panel_to_markdown() {
7312        let doc = AdfDocument {
7313            version: 1,
7314            doc_type: "doc".to_string(),
7315            content: vec![AdfNode::panel(
7316                "warning",
7317                vec![AdfNode::paragraph(vec![AdfNode::text("Be careful.")])],
7318            )],
7319        };
7320        let md = adf_to_markdown(&doc).unwrap();
7321        assert!(md.contains(":::panel{type=warning}"));
7322        assert!(md.contains("Be careful."));
7323        assert!(md.contains(":::"));
7324    }
7325
7326    #[test]
7327    fn round_trip_panel() {
7328        let md = ":::panel{type=info}\nThis is informational.\n:::\n";
7329        let doc = markdown_to_adf(md).unwrap();
7330        let result = adf_to_markdown(&doc).unwrap();
7331        assert!(result.contains(":::panel{type=info}"));
7332        assert!(result.contains("This is informational."));
7333    }
7334
7335    #[test]
7336    fn expand_with_title() {
7337        let md = ":::expand{title=\"Click me\"}\nHidden content.\n:::";
7338        let doc = markdown_to_adf(md).unwrap();
7339        assert_eq!(doc.content[0].node_type, "expand");
7340        assert_eq!(doc.content[0].attrs.as_ref().unwrap()["title"], "Click me");
7341    }
7342
7343    #[test]
7344    fn adf_expand_to_markdown() {
7345        let doc = AdfDocument {
7346            version: 1,
7347            doc_type: "doc".to_string(),
7348            content: vec![AdfNode::expand(
7349                Some("Details"),
7350                vec![AdfNode::paragraph(vec![AdfNode::text("Inner.")])],
7351            )],
7352        };
7353        let md = adf_to_markdown(&doc).unwrap();
7354        assert!(md.contains(":::expand{title=\"Details\"}"));
7355        assert!(md.contains("Inner."));
7356    }
7357
7358    #[test]
7359    fn round_trip_expand() {
7360        let md = ":::expand{title=\"Details\"}\nInner content.\n:::\n";
7361        let doc = markdown_to_adf(md).unwrap();
7362        let result = adf_to_markdown(&doc).unwrap();
7363        assert!(result.contains(":::expand{title=\"Details\"}"));
7364        assert!(result.contains("Inner content."));
7365    }
7366
7367    #[test]
7368    fn layout_two_columns() {
7369        let md =
7370            "::::layout\n:::column{width=50}\nLeft.\n:::\n:::column{width=50}\nRight.\n:::\n::::";
7371        let doc = markdown_to_adf(md).unwrap();
7372        assert_eq!(doc.content[0].node_type, "layoutSection");
7373        let columns = doc.content[0].content.as_ref().unwrap();
7374        assert_eq!(columns.len(), 2);
7375        assert_eq!(columns[0].node_type, "layoutColumn");
7376        assert_eq!(columns[1].node_type, "layoutColumn");
7377    }
7378
7379    #[test]
7380    fn adf_layout_to_markdown() {
7381        let doc = AdfDocument {
7382            version: 1,
7383            doc_type: "doc".to_string(),
7384            content: vec![AdfNode::layout_section(vec![
7385                AdfNode::layout_column(
7386                    50.0,
7387                    vec![AdfNode::paragraph(vec![AdfNode::text("Left.")])],
7388                ),
7389                AdfNode::layout_column(
7390                    50.0,
7391                    vec![AdfNode::paragraph(vec![AdfNode::text("Right.")])],
7392                ),
7393            ])],
7394        };
7395        let md = adf_to_markdown(&doc).unwrap();
7396        assert!(md.contains("::::layout"));
7397        assert!(md.contains(":::column{width=50}"));
7398        assert!(md.contains("Left."));
7399        assert!(md.contains("Right."));
7400    }
7401
7402    #[test]
7403    fn layout_column_localid_roundtrip() {
7404        let adf_json = r#"{
7405            "version": 1,
7406            "type": "doc",
7407            "content": [{
7408                "type": "layoutSection",
7409                "content": [
7410                    {
7411                        "type": "layoutColumn",
7412                        "attrs": {"width": 50.0, "localId": "aabb112233cc"},
7413                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Left"}]}]
7414                    },
7415                    {
7416                        "type": "layoutColumn",
7417                        "attrs": {"width": 50.0, "localId": "ddeeff445566"},
7418                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Right"}]}]
7419                    }
7420                ]
7421            }]
7422        }"#;
7423        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
7424        let md = adf_to_markdown(&doc).unwrap();
7425        assert!(
7426            md.contains("localId=aabb112233cc"),
7427            "first column localId should appear in markdown: {md}"
7428        );
7429        assert!(
7430            md.contains("localId=ddeeff445566"),
7431            "second column localId should appear in markdown: {md}"
7432        );
7433        let rt = markdown_to_adf(&md).unwrap();
7434        let cols = rt.content[0].content.as_ref().unwrap();
7435        assert_eq!(
7436            cols[0].attrs.as_ref().unwrap()["localId"],
7437            "aabb112233cc",
7438            "first column localId should round-trip"
7439        );
7440        assert_eq!(
7441            cols[1].attrs.as_ref().unwrap()["localId"],
7442            "ddeeff445566",
7443            "second column localId should round-trip"
7444        );
7445    }
7446
7447    #[test]
7448    fn layout_column_without_localid() {
7449        let md =
7450            "::::layout\n:::column{width=50}\nLeft.\n:::\n:::column{width=50}\nRight.\n:::\n::::";
7451        let doc = markdown_to_adf(md).unwrap();
7452        let cols = doc.content[0].content.as_ref().unwrap();
7453        assert!(
7454            cols[0].attrs.as_ref().unwrap().get("localId").is_none(),
7455            "column without localId should not gain one"
7456        );
7457        let md2 = adf_to_markdown(&doc).unwrap();
7458        assert!(
7459            !md2.contains("localId"),
7460            "no localId should appear in output: {md2}"
7461        );
7462    }
7463
7464    #[test]
7465    fn layout_column_localid_stripped_when_option_set() {
7466        let adf_json = r#"{
7467            "version": 1,
7468            "type": "doc",
7469            "content": [{
7470                "type": "layoutSection",
7471                "content": [{
7472                    "type": "layoutColumn",
7473                    "attrs": {"width": 50.0, "localId": "aabb112233cc"},
7474                    "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Col"}]}]
7475                }]
7476            }]
7477        }"#;
7478        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
7479        let opts = RenderOptions {
7480            strip_local_ids: true,
7481            ..Default::default()
7482        };
7483        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
7484        assert!(!md.contains("localId"), "localId should be stripped: {md}");
7485    }
7486
7487    #[test]
7488    fn layout_column_localid_flush_previous() {
7489        // Columns open without explicit `:::` close → flush-previous path
7490        let md = "::::layout\n:::column{width=50 localId=aabb112233cc}\nLeft.\n:::column{width=50 localId=ddeeff445566}\nRight.\n:::\n::::";
7491        let doc = markdown_to_adf(md).unwrap();
7492        let cols = doc.content[0].content.as_ref().unwrap();
7493        assert_eq!(
7494            cols[0].attrs.as_ref().unwrap()["localId"],
7495            "aabb112233cc",
7496            "flush-previous column should preserve localId"
7497        );
7498        assert_eq!(
7499            cols[1].attrs.as_ref().unwrap()["localId"],
7500            "ddeeff445566",
7501            "second column localId should be preserved"
7502        );
7503    }
7504
7505    #[test]
7506    fn layout_column_localid_flush_last() {
7507        // Layout with no closing fence → column never explicitly closed → flush-last path
7508        let md = "::::layout\n:::column{width=50 localId=aabb112233cc}\nOnly column.";
7509        let doc = markdown_to_adf(md).unwrap();
7510        let cols = doc.content[0].content.as_ref().unwrap();
7511        assert_eq!(
7512            cols[0].attrs.as_ref().unwrap()["localId"],
7513            "aabb112233cc",
7514            "flush-last column should preserve localId"
7515        );
7516    }
7517
7518    #[test]
7519    fn decisions_list() {
7520        let md = ":::decisions\n- <> Use PostgreSQL\n- <> REST API\n:::";
7521        let doc = markdown_to_adf(md).unwrap();
7522        assert_eq!(doc.content[0].node_type, "decisionList");
7523        let items = doc.content[0].content.as_ref().unwrap();
7524        assert_eq!(items.len(), 2);
7525        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DECIDED");
7526    }
7527
7528    #[test]
7529    fn adf_decisions_to_markdown() {
7530        let doc = AdfDocument {
7531            version: 1,
7532            doc_type: "doc".to_string(),
7533            content: vec![AdfNode::decision_list(vec![AdfNode::decision_item(
7534                "DECIDED",
7535                vec![AdfNode::paragraph(vec![AdfNode::text("Use PostgreSQL")])],
7536            )])],
7537        };
7538        let md = adf_to_markdown(&doc).unwrap();
7539        assert!(md.contains(":::decisions"));
7540        assert!(md.contains("- <> Use PostgreSQL"));
7541    }
7542
7543    #[test]
7544    fn bodied_extension_container() {
7545        let md = ":::extension{type=com.forge key=my-macro}\nContent.\n:::";
7546        let doc = markdown_to_adf(md).unwrap();
7547        assert_eq!(doc.content[0].node_type, "bodiedExtension");
7548        assert_eq!(
7549            doc.content[0].attrs.as_ref().unwrap()["extensionType"],
7550            "com.forge"
7551        );
7552    }
7553
7554    #[test]
7555    fn adf_bodied_extension_to_markdown() {
7556        let doc = AdfDocument {
7557            version: 1,
7558            doc_type: "doc".to_string(),
7559            content: vec![AdfNode::bodied_extension(
7560                "com.forge",
7561                "my-macro",
7562                vec![AdfNode::paragraph(vec![AdfNode::text("Content.")])],
7563            )],
7564        };
7565        let md = adf_to_markdown(&doc).unwrap();
7566        assert!(md.contains(":::extension{type=com.forge key=my-macro}"));
7567        assert!(md.contains("Content."));
7568    }
7569
7570    // ── Leaf block directive tests (Tier 3) ──────────────────────────
7571
7572    #[test]
7573    fn leaf_block_card() {
7574        let doc = markdown_to_adf("::card[https://example.com/browse/PROJ-123]").unwrap();
7575        assert_eq!(doc.content[0].node_type, "blockCard");
7576        assert_eq!(
7577            doc.content[0].attrs.as_ref().unwrap()["url"],
7578            "https://example.com/browse/PROJ-123"
7579        );
7580    }
7581
7582    #[test]
7583    fn adf_block_card_to_markdown() {
7584        let doc = AdfDocument {
7585            version: 1,
7586            doc_type: "doc".to_string(),
7587            content: vec![AdfNode::block_card("https://example.com/browse/PROJ-123")],
7588        };
7589        let md = adf_to_markdown(&doc).unwrap();
7590        assert!(md.contains("::card[https://example.com/browse/PROJ-123]"));
7591    }
7592
7593    #[test]
7594    fn round_trip_block_card() {
7595        let md = "::card[https://example.com/browse/PROJ-123]\n";
7596        let doc = markdown_to_adf(md).unwrap();
7597        let result = adf_to_markdown(&doc).unwrap();
7598        assert!(result.contains("::card[https://example.com/browse/PROJ-123]"));
7599    }
7600
7601    #[test]
7602    fn leaf_embed_card() {
7603        let doc =
7604            markdown_to_adf("::embed[https://figma.com/file/abc]{layout=wide width=80}").unwrap();
7605        assert_eq!(doc.content[0].node_type, "embedCard");
7606        let attrs = doc.content[0].attrs.as_ref().unwrap();
7607        assert_eq!(attrs["url"], "https://figma.com/file/abc");
7608        assert_eq!(attrs["layout"], "wide");
7609        assert_eq!(attrs["width"], 80.0);
7610    }
7611
7612    #[test]
7613    fn leaf_embed_card_with_original_height() {
7614        let doc = markdown_to_adf(
7615            "::embed[https://example.com]{layout=center originalHeight=732 width=100}",
7616        )
7617        .unwrap();
7618        assert_eq!(doc.content[0].node_type, "embedCard");
7619        let attrs = doc.content[0].attrs.as_ref().unwrap();
7620        assert_eq!(attrs["url"], "https://example.com");
7621        assert_eq!(attrs["layout"], "center");
7622        assert_eq!(attrs["originalHeight"], 732.0);
7623        assert_eq!(attrs["width"], 100.0);
7624    }
7625
7626    #[test]
7627    fn adf_embed_card_to_markdown() {
7628        let doc = AdfDocument {
7629            version: 1,
7630            doc_type: "doc".to_string(),
7631            content: vec![AdfNode::embed_card(
7632                "https://figma.com/file/abc",
7633                Some("wide"),
7634                None,
7635                Some(80.0),
7636            )],
7637        };
7638        let md = adf_to_markdown(&doc).unwrap();
7639        assert!(md.contains("::embed[https://figma.com/file/abc]{layout=wide width=80}"));
7640    }
7641
7642    #[test]
7643    fn adf_embed_card_original_height_to_markdown() {
7644        let doc = AdfDocument {
7645            version: 1,
7646            doc_type: "doc".to_string(),
7647            content: vec![AdfNode::embed_card(
7648                "https://example.com",
7649                Some("center"),
7650                Some(732.0),
7651                Some(100.0),
7652            )],
7653        };
7654        let md = adf_to_markdown(&doc).unwrap();
7655        assert!(
7656            md.contains("::embed[https://example.com]{layout=center originalHeight=732 width=100}"),
7657            "expected originalHeight and width in md: {md}"
7658        );
7659    }
7660
7661    #[test]
7662    fn embed_card_roundtrip_with_all_attrs() {
7663        let adf_json = r#"{"version":1,"type":"doc","content":[{
7664            "type":"embedCard",
7665            "attrs":{"layout":"center","originalHeight":732.0,"url":"https://example.com","width":100.0}
7666        }]}"#;
7667        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
7668        let md = adf_to_markdown(&doc).unwrap();
7669        assert!(
7670            md.contains("originalHeight=732"),
7671            "originalHeight missing from md: {md}"
7672        );
7673        assert!(md.contains("width=100"), "width missing from md: {md}");
7674        let rt = markdown_to_adf(&md).unwrap();
7675        let attrs = rt.content[0].attrs.as_ref().unwrap();
7676        assert_eq!(attrs["originalHeight"], 732.0);
7677        assert_eq!(attrs["width"], 100.0);
7678        assert_eq!(attrs["layout"], "center");
7679        assert_eq!(attrs["url"], "https://example.com");
7680    }
7681
7682    #[test]
7683    fn embed_card_fractional_dimensions() {
7684        let doc = AdfDocument {
7685            version: 1,
7686            doc_type: "doc".to_string(),
7687            content: vec![AdfNode::embed_card(
7688                "https://example.com",
7689                Some("center"),
7690                Some(732.5),
7691                Some(99.9),
7692            )],
7693        };
7694        let md = adf_to_markdown(&doc).unwrap();
7695        assert!(
7696            md.contains("originalHeight=732.5"),
7697            "fractional originalHeight missing: {md}"
7698        );
7699        assert!(md.contains("width=99.9"), "fractional width missing: {md}");
7700        let rt = markdown_to_adf(&md).unwrap();
7701        let attrs = rt.content[0].attrs.as_ref().unwrap();
7702        assert_eq!(attrs["originalHeight"], 732.5);
7703        assert_eq!(attrs["width"], 99.9);
7704    }
7705
7706    #[test]
7707    fn embed_card_integer_width_in_json() {
7708        // JSON integer (not float) should also be extracted via as_f64()
7709        let adf_json = r#"{"version":1,"type":"doc","content":[{
7710            "type":"embedCard",
7711            "attrs":{"url":"https://example.com","width":100}
7712        }]}"#;
7713        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
7714        let md = adf_to_markdown(&doc).unwrap();
7715        assert!(
7716            md.contains("width=100"),
7717            "integer width missing from md: {md}"
7718        );
7719        let rt = markdown_to_adf(&md).unwrap();
7720        assert_eq!(rt.content[0].attrs.as_ref().unwrap()["width"], 100.0);
7721    }
7722
7723    #[test]
7724    fn embed_card_only_original_height() {
7725        // originalHeight without width
7726        let adf_json = r#"{"version":1,"type":"doc","content":[{
7727            "type":"embedCard",
7728            "attrs":{"url":"https://example.com","originalHeight":500.0}
7729        }]}"#;
7730        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
7731        let md = adf_to_markdown(&doc).unwrap();
7732        assert!(
7733            md.contains("originalHeight=500"),
7734            "originalHeight missing: {md}"
7735        );
7736        assert!(!md.contains("width="), "width should not appear: {md}");
7737        let rt = markdown_to_adf(&md).unwrap();
7738        let attrs = rt.content[0].attrs.as_ref().unwrap();
7739        assert_eq!(attrs["originalHeight"], 500.0);
7740        assert!(attrs.get("width").is_none());
7741    }
7742
7743    #[test]
7744    fn leaf_void_extension() {
7745        let doc = markdown_to_adf("::extension{type=com.atlassian.macro key=jira-chart}").unwrap();
7746        assert_eq!(doc.content[0].node_type, "extension");
7747        assert_eq!(
7748            doc.content[0].attrs.as_ref().unwrap()["extensionType"],
7749            "com.atlassian.macro"
7750        );
7751        assert_eq!(
7752            doc.content[0].attrs.as_ref().unwrap()["extensionKey"],
7753            "jira-chart"
7754        );
7755    }
7756
7757    #[test]
7758    fn adf_void_extension_to_markdown() {
7759        let doc = AdfDocument {
7760            version: 1,
7761            doc_type: "doc".to_string(),
7762            content: vec![AdfNode::extension(
7763                "com.atlassian.macro",
7764                "jira-chart",
7765                None,
7766            )],
7767        };
7768        let md = adf_to_markdown(&doc).unwrap();
7769        assert!(md.contains("::extension{type=com.atlassian.macro key=jira-chart}"));
7770    }
7771
7772    // ── Bare URL autolink tests ──────────────────────────────────────
7773
7774    #[test]
7775    fn bare_url_autolink() {
7776        let doc = markdown_to_adf("Visit https://example.com today").unwrap();
7777        let content = doc.content[0].content.as_ref().unwrap();
7778        assert_eq!(content[0].text.as_deref(), Some("Visit "));
7779        assert_eq!(content[1].node_type, "inlineCard");
7780        assert_eq!(
7781            content[1].attrs.as_ref().unwrap()["url"],
7782            "https://example.com"
7783        );
7784        assert_eq!(content[2].text.as_deref(), Some(" today"));
7785    }
7786
7787    #[test]
7788    fn bare_url_strips_trailing_punctuation() {
7789        let doc = markdown_to_adf("See https://example.com.").unwrap();
7790        let content = doc.content[0].content.as_ref().unwrap();
7791        assert_eq!(
7792            content[1].attrs.as_ref().unwrap()["url"],
7793            "https://example.com"
7794        );
7795    }
7796
7797    #[test]
7798    fn bare_url_round_trip() {
7799        let doc = markdown_to_adf("Visit https://example.com/path today").unwrap();
7800        let md = adf_to_markdown(&doc).unwrap();
7801        assert!(md.contains(":card[https://example.com/path]"));
7802    }
7803
7804    // ── Issue #475: plain-text URL must not become inlineCard ─────────
7805
7806    #[test]
7807    fn plain_text_url_round_trips_as_text() {
7808        // A text node whose content is a bare URL (no link mark) must
7809        // survive ADF→JFM→ADF as a text node, not an inlineCard.
7810        let adf_json = r#"{
7811            "version": 1,
7812            "type": "doc",
7813            "content": [{
7814                "type": "paragraph",
7815                "content": [
7816                    {"type": "text", "text": "https://example.com/some/path/to/resource"}
7817                ]
7818            }]
7819        }"#;
7820        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7821        let jfm = adf_to_markdown(&adf).unwrap();
7822        let roundtripped = markdown_to_adf(&jfm).unwrap();
7823        let content = roundtripped.content[0].content.as_ref().unwrap();
7824        assert_eq!(content.len(), 1, "should be a single node");
7825        assert_eq!(content[0].node_type, "text");
7826        assert_eq!(
7827            content[0].text.as_deref(),
7828            Some("https://example.com/some/path/to/resource")
7829        );
7830    }
7831
7832    #[test]
7833    fn url_text_with_link_mark_round_trips_as_text_node() {
7834        // Issue #523: A text node whose content is a URL with a link mark
7835        // (href differs by trailing slash) must round-trip as text+link,
7836        // not become an inlineCard.
7837        let adf_json = r#"{
7838            "version": 1,
7839            "type": "doc",
7840            "content": [{
7841                "type": "paragraph",
7842                "content": [{
7843                    "type": "text",
7844                    "text": "https://octopz.example.com",
7845                    "marks": [{"type": "link", "attrs": {"href": "https://octopz.example.com/"}}]
7846                }]
7847            }]
7848        }"#;
7849        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7850        let jfm = adf_to_markdown(&adf).unwrap();
7851        let roundtripped = markdown_to_adf(&jfm).unwrap();
7852        let content = roundtripped.content[0].content.as_ref().unwrap();
7853        assert_eq!(content.len(), 1, "should be a single node");
7854        assert_eq!(content[0].node_type, "text", "must be text, not inlineCard");
7855        assert_eq!(
7856            content[0].text.as_deref(),
7857            Some("https://octopz.example.com")
7858        );
7859        let mark = &content[0].marks.as_ref().unwrap()[0];
7860        assert_eq!(mark.mark_type, "link");
7861        assert_eq!(
7862            mark.attrs.as_ref().unwrap()["href"],
7863            "https://octopz.example.com/"
7864        );
7865    }
7866
7867    #[test]
7868    fn url_text_with_exact_link_mark_round_trips() {
7869        // Variant: text and href are identical (no trailing slash difference).
7870        let adf_json = r#"{
7871            "version": 1,
7872            "type": "doc",
7873            "content": [{
7874                "type": "paragraph",
7875                "content": [{
7876                    "type": "text",
7877                    "text": "https://example.com/path",
7878                    "marks": [{"type": "link", "attrs": {"href": "https://example.com/path"}}]
7879                }]
7880            }]
7881        }"#;
7882        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7883        let jfm = adf_to_markdown(&adf).unwrap();
7884        let roundtripped = markdown_to_adf(&jfm).unwrap();
7885        let content = roundtripped.content[0].content.as_ref().unwrap();
7886        assert_eq!(content.len(), 1, "should be a single node");
7887        assert_eq!(content[0].node_type, "text");
7888        assert_eq!(content[0].text.as_deref(), Some("https://example.com/path"));
7889        let mark = &content[0].marks.as_ref().unwrap()[0];
7890        assert_eq!(mark.mark_type, "link");
7891    }
7892
7893    #[test]
7894    fn plain_text_url_amid_text_round_trips() {
7895        // URL embedded in surrounding text, without link mark.
7896        let adf_json = r#"{
7897            "version": 1,
7898            "type": "doc",
7899            "content": [{
7900                "type": "paragraph",
7901                "content": [
7902                    {"type": "text", "text": "see https://example.com for info"}
7903                ]
7904            }]
7905        }"#;
7906        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7907        let jfm = adf_to_markdown(&adf).unwrap();
7908        let roundtripped = markdown_to_adf(&jfm).unwrap();
7909        let content = roundtripped.content[0].content.as_ref().unwrap();
7910        assert_eq!(content.len(), 1);
7911        assert_eq!(content[0].node_type, "text");
7912        assert_eq!(
7913            content[0].text.as_deref(),
7914            Some("see https://example.com for info")
7915        );
7916    }
7917
7918    #[test]
7919    fn plain_text_multiple_urls_round_trips() {
7920        let adf_json = r#"{
7921            "version": 1,
7922            "type": "doc",
7923            "content": [{
7924                "type": "paragraph",
7925                "content": [
7926                    {"type": "text", "text": "http://a.com and https://b.com"}
7927                ]
7928            }]
7929        }"#;
7930        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7931        let jfm = adf_to_markdown(&adf).unwrap();
7932        let roundtripped = markdown_to_adf(&jfm).unwrap();
7933        let content = roundtripped.content[0].content.as_ref().unwrap();
7934        assert_eq!(content.len(), 1);
7935        assert_eq!(content[0].node_type, "text");
7936        assert_eq!(
7937            content[0].text.as_deref(),
7938            Some("http://a.com and https://b.com")
7939        );
7940    }
7941
7942    #[test]
7943    fn plain_text_http_prefix_no_url_unchanged() {
7944        // "http" without "://" should not be escaped or altered.
7945        let adf_json = r#"{
7946            "version": 1,
7947            "type": "doc",
7948            "content": [{
7949                "type": "paragraph",
7950                "content": [
7951                    {"type": "text", "text": "the http header is important"}
7952                ]
7953            }]
7954        }"#;
7955        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7956        let jfm = adf_to_markdown(&adf).unwrap();
7957        let roundtripped = markdown_to_adf(&jfm).unwrap();
7958        let content = roundtripped.content[0].content.as_ref().unwrap();
7959        assert_eq!(
7960            content[0].text.as_deref(),
7961            Some("the http header is important")
7962        );
7963    }
7964
7965    #[test]
7966    fn linked_url_text_not_double_escaped() {
7967        // A text node with a link mark should render as [text](url),
7968        // not escape the URL in the link text.
7969        let adf_json = r#"{
7970            "version": 1,
7971            "type": "doc",
7972            "content": [{
7973                "type": "paragraph",
7974                "content": [{
7975                    "type": "text",
7976                    "text": "https://example.com",
7977                    "marks": [{"type": "link", "attrs": {"href": "https://example.com"}}]
7978                }]
7979            }]
7980        }"#;
7981        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7982        let jfm = adf_to_markdown(&adf).unwrap();
7983        // Should render as a self-link, not as escaped text
7984        assert!(!jfm.contains(r"\https"));
7985        // Round-trip should preserve the link mark
7986        let roundtripped = markdown_to_adf(&jfm).unwrap();
7987        let content = roundtripped.content[0].content.as_ref().unwrap();
7988        let has_link = content.iter().any(|n| {
7989            n.marks
7990                .as_ref()
7991                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "link"))
7992        });
7993        assert!(has_link, "link mark should be preserved");
7994    }
7995
7996    // ── Issue #493: bracket-link ambiguity ─────────────────────────────
7997
7998    #[test]
7999    fn escape_link_brackets_unit() {
8000        assert_eq!(escape_link_brackets("hello"), "hello");
8001        assert_eq!(escape_link_brackets("["), "\\[");
8002        assert_eq!(escape_link_brackets("]"), "\\]");
8003        assert_eq!(escape_link_brackets("[PROJ-456]"), "\\[PROJ-456\\]");
8004        assert_eq!(escape_link_brackets("a[b]c"), "a\\[b\\]c");
8005    }
8006
8007    #[test]
8008    fn bracket_text_with_link_mark_escapes_brackets() {
8009        // A text node whose content is "[" with a link mark should escape
8010        // the bracket so it does not create ambiguous markdown link syntax.
8011        let doc = AdfDocument {
8012            version: 1,
8013            doc_type: "doc".to_string(),
8014            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
8015                "[",
8016                vec![AdfMark::link("https://example.com")],
8017            )])],
8018        };
8019        let md = adf_to_markdown(&doc).unwrap();
8020        assert_eq!(md.trim(), "[\\[](https://example.com)");
8021    }
8022
8023    #[test]
8024    fn bracket_text_with_link_mark_round_trips() {
8025        // Issue #493 reproducer: adjacent text nodes sharing a link mark
8026        // where the first node's content is "[".
8027        let adf_json = r#"{
8028            "type": "doc",
8029            "version": 1,
8030            "content": [{
8031                "type": "paragraph",
8032                "content": [
8033                    {
8034                        "type": "text",
8035                        "text": "[",
8036                        "marks": [{"type": "link", "attrs": {"href": "https://example.com/ticket/123"}}]
8037                    },
8038                    {
8039                        "type": "text",
8040                        "text": "PROJ-456] Fix the auth bug",
8041                        "marks": [
8042                            {"type": "underline"},
8043                            {"type": "link", "attrs": {"href": "https://example.com/ticket/123"}}
8044                        ]
8045                    }
8046                ]
8047            }]
8048        }"#;
8049        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
8050        let jfm = adf_to_markdown(&adf).unwrap();
8051
8052        // The markdown should contain escaped brackets inside the link
8053        assert!(jfm.contains("\\["), "opening bracket should be escaped");
8054
8055        // Round-trip: both text nodes must survive with link marks
8056        let rt = markdown_to_adf(&jfm).unwrap();
8057        let content = rt.content[0].content.as_ref().unwrap();
8058
8059        // All text nodes that were part of the link must still carry a link mark
8060        let link_nodes: Vec<_> = content
8061            .iter()
8062            .filter(|n| {
8063                n.marks
8064                    .as_ref()
8065                    .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "link"))
8066            })
8067            .collect();
8068        assert!(
8069            !link_nodes.is_empty(),
8070            "link mark must be preserved on round-trip"
8071        );
8072
8073        // The combined text across all nodes should contain the original content
8074        let all_text: String = content.iter().filter_map(|n| n.text.as_deref()).collect();
8075        assert!(
8076            all_text.contains('['),
8077            "literal '[' must survive round-trip"
8078        );
8079        assert!(
8080            all_text.contains("PROJ-456]"),
8081            "continuation text must survive round-trip"
8082        );
8083    }
8084
8085    #[test]
8086    fn closing_bracket_in_link_text_round_trips() {
8087        // A text node containing "]" inside a link should be escaped and
8088        // survive round-trip without breaking the link syntax.
8089        let doc = AdfDocument {
8090            version: 1,
8091            doc_type: "doc".to_string(),
8092            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
8093                "item]",
8094                vec![AdfMark::link("https://example.com")],
8095            )])],
8096        };
8097        let md = adf_to_markdown(&doc).unwrap();
8098        assert_eq!(md.trim(), "[item\\]](https://example.com)");
8099
8100        let rt = markdown_to_adf(&md).unwrap();
8101        let content = rt.content[0].content.as_ref().unwrap();
8102        assert_eq!(content[0].text.as_deref(), Some("item]"));
8103        assert!(content[0]
8104            .marks
8105            .as_ref()
8106            .unwrap()
8107            .iter()
8108            .any(|m| m.mark_type == "link"));
8109    }
8110
8111    #[test]
8112    fn brackets_in_link_text_round_trip() {
8113        // Text containing both [ and ] inside a link should round-trip.
8114        let doc = AdfDocument {
8115            version: 1,
8116            doc_type: "doc".to_string(),
8117            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
8118                "[PROJ-123]",
8119                vec![AdfMark::link("https://example.com")],
8120            )])],
8121        };
8122        let md = adf_to_markdown(&doc).unwrap();
8123        assert_eq!(md.trim(), "[\\[PROJ-123\\]](https://example.com)");
8124
8125        let rt = markdown_to_adf(&md).unwrap();
8126        let content = rt.content[0].content.as_ref().unwrap();
8127        assert_eq!(content[0].text.as_deref(), Some("[PROJ-123]"));
8128        assert!(content[0]
8129            .marks
8130            .as_ref()
8131            .unwrap()
8132            .iter()
8133            .any(|m| m.mark_type == "link"));
8134    }
8135
8136    #[test]
8137    fn plain_text_brackets_not_escaped() {
8138        // Brackets in plain text (no link mark) must NOT be escaped.
8139        let doc = AdfDocument {
8140            version: 1,
8141            doc_type: "doc".to_string(),
8142            content: vec![AdfNode::paragraph(vec![AdfNode::text(
8143                "see [PROJ-123] for details",
8144            )])],
8145        };
8146        let md = adf_to_markdown(&doc).unwrap();
8147        assert_eq!(md.trim(), "see [PROJ-123] for details");
8148    }
8149
8150    #[test]
8151    fn link_with_no_brackets_unchanged() {
8152        // A normal link with no brackets in the text should be unaffected.
8153        let doc = AdfDocument {
8154            version: 1,
8155            doc_type: "doc".to_string(),
8156            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
8157                "click here",
8158                vec![AdfMark::link("https://example.com")],
8159            )])],
8160        };
8161        let md = adf_to_markdown(&doc).unwrap();
8162        assert_eq!(md.trim(), "[click here](https://example.com)");
8163    }
8164
8165    #[test]
8166    fn inline_card_still_round_trips() {
8167        // An actual inlineCard node should still round-trip correctly
8168        // (it uses :card[url] syntax, not bare URL).
8169        let adf_json = r#"{
8170            "version": 1,
8171            "type": "doc",
8172            "content": [{
8173                "type": "paragraph",
8174                "content": [
8175                    {"type": "inlineCard", "attrs": {"url": "https://example.com/page"}}
8176                ]
8177            }]
8178        }"#;
8179        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
8180        let jfm = adf_to_markdown(&adf).unwrap();
8181        assert!(jfm.contains(":card[https://example.com/page]"));
8182        let roundtripped = markdown_to_adf(&jfm).unwrap();
8183        let content = roundtripped.content[0].content.as_ref().unwrap();
8184        assert_eq!(content[0].node_type, "inlineCard");
8185        assert_eq!(
8186            content[0].attrs.as_ref().unwrap()["url"],
8187            "https://example.com/page"
8188        );
8189    }
8190
8191    // ── Block-level attribute marks (Tier 5/6) ───────────────────────
8192
8193    #[test]
8194    fn paragraph_align_center() {
8195        let md = "Centered text.\n{align=center}";
8196        let doc = markdown_to_adf(md).unwrap();
8197        let marks = doc.content[0].marks.as_ref().unwrap();
8198        assert_eq!(marks[0].mark_type, "alignment");
8199        assert_eq!(marks[0].attrs.as_ref().unwrap()["align"], "center");
8200    }
8201
8202    #[test]
8203    fn adf_alignment_to_markdown() {
8204        let mut node = AdfNode::paragraph(vec![AdfNode::text("Centered.")]);
8205        node.marks = Some(vec![AdfMark::alignment("center")]);
8206        let doc = AdfDocument {
8207            version: 1,
8208            doc_type: "doc".to_string(),
8209            content: vec![node],
8210        };
8211        let md = adf_to_markdown(&doc).unwrap();
8212        assert!(md.contains("Centered."));
8213        assert!(md.contains("{align=center}"));
8214    }
8215
8216    #[test]
8217    fn round_trip_alignment() {
8218        let md = "Centered.\n{align=center}\n";
8219        let doc = markdown_to_adf(md).unwrap();
8220        let result = adf_to_markdown(&doc).unwrap();
8221        assert!(result.contains("{align=center}"));
8222    }
8223
8224    #[test]
8225    fn paragraph_indent() {
8226        let md = "Indented.\n{indent=2}";
8227        let doc = markdown_to_adf(md).unwrap();
8228        let marks = doc.content[0].marks.as_ref().unwrap();
8229        assert_eq!(marks[0].mark_type, "indentation");
8230        assert_eq!(marks[0].attrs.as_ref().unwrap()["level"], 2);
8231    }
8232
8233    #[test]
8234    fn code_block_breakout() {
8235        let md = "```python\ndef f(): pass\n```\n{breakout=wide}";
8236        let doc = markdown_to_adf(md).unwrap();
8237        let marks = doc.content[0].marks.as_ref().unwrap();
8238        assert_eq!(marks[0].mark_type, "breakout");
8239        assert_eq!(marks[0].attrs.as_ref().unwrap()["mode"], "wide");
8240        assert!(marks[0].attrs.as_ref().unwrap().get("width").is_none());
8241    }
8242
8243    #[test]
8244    fn code_block_breakout_with_width() {
8245        let md = "```python\ndef f(): pass\n```\n{breakout=wide breakoutWidth=1200}";
8246        let doc = markdown_to_adf(md).unwrap();
8247        let marks = doc.content[0].marks.as_ref().unwrap();
8248        assert_eq!(marks[0].mark_type, "breakout");
8249        assert_eq!(marks[0].attrs.as_ref().unwrap()["mode"], "wide");
8250        assert_eq!(marks[0].attrs.as_ref().unwrap()["width"], 1200);
8251    }
8252
8253    #[test]
8254    fn adf_breakout_to_markdown() {
8255        let mut node = AdfNode::code_block(Some("python"), "pass");
8256        node.marks = Some(vec![AdfMark::breakout("wide", None)]);
8257        let doc = AdfDocument {
8258            version: 1,
8259            doc_type: "doc".to_string(),
8260            content: vec![node],
8261        };
8262        let md = adf_to_markdown(&doc).unwrap();
8263        assert!(md.contains("{breakout=wide}"));
8264        assert!(!md.contains("breakoutWidth"));
8265    }
8266
8267    #[test]
8268    fn adf_breakout_with_width_to_markdown() {
8269        let mut node = AdfNode::code_block(Some("python"), "pass");
8270        node.marks = Some(vec![AdfMark::breakout("wide", Some(1200))]);
8271        let doc = AdfDocument {
8272            version: 1,
8273            doc_type: "doc".to_string(),
8274            content: vec![node],
8275        };
8276        let md = adf_to_markdown(&doc).unwrap();
8277        assert!(md.contains("breakout=wide"));
8278        assert!(md.contains("breakoutWidth=1200"));
8279    }
8280
8281    #[test]
8282    fn breakout_width_round_trip() {
8283        let adf_json = r#"{"version":1,"type":"doc","content":[{
8284            "type":"codeBlock",
8285            "attrs":{"language":"text"},
8286            "marks":[{"type":"breakout","attrs":{"mode":"wide","width":1200}}],
8287            "content":[{"type":"text","text":"some code"}]
8288        }]}"#;
8289        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8290        let md = adf_to_markdown(&doc).unwrap();
8291        assert!(md.contains("breakout=wide"));
8292        assert!(md.contains("breakoutWidth=1200"));
8293        let round_tripped = markdown_to_adf(&md).unwrap();
8294        let marks = round_tripped.content[0].marks.as_ref().unwrap();
8295        let breakout = marks.iter().find(|m| m.mark_type == "breakout").unwrap();
8296        assert_eq!(breakout.attrs.as_ref().unwrap()["mode"], "wide");
8297        assert_eq!(breakout.attrs.as_ref().unwrap()["width"], 1200);
8298    }
8299
8300    // ── Attribute extensions — media & table (Tier 5) ────────────────
8301
8302    #[test]
8303    fn image_with_layout_attrs() {
8304        let doc = markdown_to_adf("![alt](url){layout=wide width=80}").unwrap();
8305        let node = &doc.content[0];
8306        assert_eq!(node.node_type, "mediaSingle");
8307        let attrs = node.attrs.as_ref().unwrap();
8308        assert_eq!(attrs["layout"], "wide");
8309        assert_eq!(attrs["width"], 80);
8310    }
8311
8312    #[test]
8313    fn adf_image_with_layout_to_markdown() {
8314        let mut node = AdfNode::media_single("url", Some("alt"));
8315        node.attrs.as_mut().unwrap()["layout"] = serde_json::json!("wide");
8316        node.attrs.as_mut().unwrap()["width"] = serde_json::json!(80);
8317        let doc = AdfDocument {
8318            version: 1,
8319            doc_type: "doc".to_string(),
8320            content: vec![node],
8321        };
8322        let md = adf_to_markdown(&doc).unwrap();
8323        assert!(md.contains("![alt](url){layout=wide width=80}"));
8324    }
8325
8326    #[test]
8327    fn table_with_layout_attrs() {
8328        let md = "| H |\n| --- |\n| C |\n{layout=wide numbered}";
8329        let doc = markdown_to_adf(md).unwrap();
8330        let table = &doc.content[0];
8331        assert_eq!(table.node_type, "table");
8332        let attrs = table.attrs.as_ref().unwrap();
8333        assert_eq!(attrs["layout"], "wide");
8334        assert_eq!(attrs["isNumberColumnEnabled"], true);
8335    }
8336
8337    #[test]
8338    fn adf_table_with_attrs_to_markdown() {
8339        let mut table = AdfNode::table(vec![
8340            AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
8341                AdfNode::text("H"),
8342            ])])]),
8343            AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
8344                AdfNode::text("C"),
8345            ])])]),
8346        ]);
8347        table.attrs = Some(serde_json::json!({"layout": "wide", "isNumberColumnEnabled": true}));
8348        let doc = AdfDocument {
8349            version: 1,
8350            doc_type: "doc".to_string(),
8351            content: vec![table],
8352        };
8353        let md = adf_to_markdown(&doc).unwrap();
8354        assert!(md.contains("{layout=wide numbered}"));
8355    }
8356
8357    // ── Attribute extensions — inline marks (Tier 5) ─────────────────
8358
8359    #[test]
8360    fn underline_bracketed_span() {
8361        let doc = markdown_to_adf("This is [underlined text]{underline} here.").unwrap();
8362        let content = doc.content[0].content.as_ref().unwrap();
8363        assert_eq!(content[1].text.as_deref(), Some("underlined text"));
8364        let marks = content[1].marks.as_ref().unwrap();
8365        assert_eq!(marks[0].mark_type, "underline");
8366    }
8367
8368    #[test]
8369    fn adf_underline_to_markdown() {
8370        let doc = AdfDocument {
8371            version: 1,
8372            doc_type: "doc".to_string(),
8373            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
8374                "underlined",
8375                vec![AdfMark::underline()],
8376            )])],
8377        };
8378        let md = adf_to_markdown(&doc).unwrap();
8379        assert!(md.contains("[underlined]{underline}"));
8380    }
8381
8382    #[test]
8383    fn round_trip_underline() {
8384        let md = "This is [underlined text]{underline} here.\n";
8385        let doc = markdown_to_adf(md).unwrap();
8386        let result = adf_to_markdown(&doc).unwrap();
8387        assert!(result.contains("[underlined text]{underline}"));
8388    }
8389
8390    #[test]
8391    fn mark_ordering_underline_strong_preserved() {
8392        // Issue #383: mark ordering was non-deterministic
8393        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8394          {"type":"text","text":"bold and underlined","marks":[{"type":"underline"},{"type":"strong"}]}
8395        ]}]}"#;
8396        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8397        let md = adf_to_markdown(&doc).unwrap();
8398        let round_tripped = markdown_to_adf(&md).unwrap();
8399        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
8400        let mark_types: Vec<&str> = node
8401            .marks
8402            .as_ref()
8403            .unwrap()
8404            .iter()
8405            .map(|m| m.mark_type.as_str())
8406            .collect();
8407        assert_eq!(
8408            mark_types,
8409            vec!["underline", "strong"],
8410            "mark order should be preserved, got: {mark_types:?}"
8411        );
8412    }
8413
8414    #[test]
8415    fn mark_ordering_link_strong_preserved() {
8416        // Issue #403: link+strong mark order was swapped on round-trip
8417        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8418          {"type":"text","text":"bold link","marks":[
8419            {"type":"link","attrs":{"href":"https://example.com"}},
8420            {"type":"strong"}
8421          ]}
8422        ]}]}"#;
8423        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8424        let md = adf_to_markdown(&doc).unwrap();
8425        let round_tripped = markdown_to_adf(&md).unwrap();
8426        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
8427        let mark_types: Vec<&str> = node
8428            .marks
8429            .as_ref()
8430            .unwrap()
8431            .iter()
8432            .map(|m| m.mark_type.as_str())
8433            .collect();
8434        assert_eq!(
8435            mark_types,
8436            vec!["link", "strong"],
8437            "mark order should be preserved, got: {mark_types:?}"
8438        );
8439    }
8440
8441    #[test]
8442    fn mark_ordering_link_textcolor_preserved() {
8443        // Issue #403 comment: link+textColor mark order was swapped on round-trip
8444        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8445          {"type":"text","text":"red link","marks":[
8446            {"type":"link","attrs":{"href":"https://example.com"}},
8447            {"type":"textColor","attrs":{"color":"#ff0000"}}
8448          ]}
8449        ]}]}"##;
8450        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8451        let md = adf_to_markdown(&doc).unwrap();
8452        let round_tripped = markdown_to_adf(&md).unwrap();
8453        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
8454        let mark_types: Vec<&str> = node
8455            .marks
8456            .as_ref()
8457            .unwrap()
8458            .iter()
8459            .map(|m| m.mark_type.as_str())
8460            .collect();
8461        assert_eq!(
8462            mark_types,
8463            vec!["link", "textColor"],
8464            "mark order should be preserved, got: {mark_types:?}"
8465        );
8466    }
8467
8468    #[test]
8469    fn mark_ordering_link_em_preserved() {
8470        // Issue #403: link+em mark order should be preserved
8471        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8472          {"type":"text","text":"italic link","marks":[
8473            {"type":"link","attrs":{"href":"https://example.com"}},
8474            {"type":"em"}
8475          ]}
8476        ]}]}"#;
8477        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8478        let md = adf_to_markdown(&doc).unwrap();
8479        let round_tripped = markdown_to_adf(&md).unwrap();
8480        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
8481        let mark_types: Vec<&str> = node
8482            .marks
8483            .as_ref()
8484            .unwrap()
8485            .iter()
8486            .map(|m| m.mark_type.as_str())
8487            .collect();
8488        assert_eq!(
8489            mark_types,
8490            vec!["link", "em"],
8491            "mark order should be preserved, got: {mark_types:?}"
8492        );
8493    }
8494
8495    #[test]
8496    fn mark_ordering_link_strike_preserved() {
8497        // Issue #403: link+strike mark order should be preserved
8498        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8499          {"type":"text","text":"struck link","marks":[
8500            {"type":"link","attrs":{"href":"https://example.com"}},
8501            {"type":"strike"}
8502          ]}
8503        ]}]}"#;
8504        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8505        let md = adf_to_markdown(&doc).unwrap();
8506        let round_tripped = markdown_to_adf(&md).unwrap();
8507        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
8508        let mark_types: Vec<&str> = node
8509            .marks
8510            .as_ref()
8511            .unwrap()
8512            .iter()
8513            .map(|m| m.mark_type.as_str())
8514            .collect();
8515        assert_eq!(
8516            mark_types,
8517            vec!["link", "strike"],
8518            "mark order should be preserved, got: {mark_types:?}"
8519        );
8520    }
8521
8522    #[test]
8523    fn mark_ordering_strong_link_preserved() {
8524        // Issue #403: [strong, link] order must also be preserved (reverse direction)
8525        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8526          {"type":"text","text":"bold link","marks":[
8527            {"type":"strong"},
8528            {"type":"link","attrs":{"href":"https://example.com"}}
8529          ]}
8530        ]}]}"#;
8531        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8532        let md = adf_to_markdown(&doc).unwrap();
8533        let round_tripped = markdown_to_adf(&md).unwrap();
8534        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
8535        let mark_types: Vec<&str> = node
8536            .marks
8537            .as_ref()
8538            .unwrap()
8539            .iter()
8540            .map(|m| m.mark_type.as_str())
8541            .collect();
8542        assert_eq!(
8543            mark_types,
8544            vec!["strong", "link"],
8545            "mark order should be preserved, got: {mark_types:?}"
8546        );
8547    }
8548
8549    #[test]
8550    fn mark_ordering_em_link_preserved() {
8551        // Issue #403: [em, link] order must also be preserved
8552        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8553          {"type":"text","text":"italic link","marks":[
8554            {"type":"em"},
8555            {"type":"link","attrs":{"href":"https://example.com"}}
8556          ]}
8557        ]}]}"#;
8558        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8559        let md = adf_to_markdown(&doc).unwrap();
8560        let round_tripped = markdown_to_adf(&md).unwrap();
8561        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
8562        let mark_types: Vec<&str> = node
8563            .marks
8564            .as_ref()
8565            .unwrap()
8566            .iter()
8567            .map(|m| m.mark_type.as_str())
8568            .collect();
8569        assert_eq!(
8570            mark_types,
8571            vec!["em", "link"],
8572            "mark order should be preserved, got: {mark_types:?}"
8573        );
8574    }
8575
8576    #[test]
8577    fn mark_ordering_strike_link_preserved() {
8578        // Issue #403: [strike, link] order must also be preserved
8579        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8580          {"type":"text","text":"struck link","marks":[
8581            {"type":"strike"},
8582            {"type":"link","attrs":{"href":"https://example.com"}}
8583          ]}
8584        ]}]}"#;
8585        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8586        let md = adf_to_markdown(&doc).unwrap();
8587        let round_tripped = markdown_to_adf(&md).unwrap();
8588        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
8589        let mark_types: Vec<&str> = node
8590            .marks
8591            .as_ref()
8592            .unwrap()
8593            .iter()
8594            .map(|m| m.mark_type.as_str())
8595            .collect();
8596        assert_eq!(
8597            mark_types,
8598            vec!["strike", "link"],
8599            "mark order should be preserved, got: {mark_types:?}"
8600        );
8601    }
8602
8603    #[test]
8604    fn mark_ordering_underline_link_preserved() {
8605        // Issue #403: [underline, link] order must be preserved
8606        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8607          {"type":"text","text":"click here","marks":[
8608            {"type":"underline"},
8609            {"type":"link","attrs":{"href":"https://example.com"}}
8610          ]}
8611        ]}]}"#;
8612        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8613        let md = adf_to_markdown(&doc).unwrap();
8614        let round_tripped = markdown_to_adf(&md).unwrap();
8615        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
8616        let mark_types: Vec<&str> = node
8617            .marks
8618            .as_ref()
8619            .unwrap()
8620            .iter()
8621            .map(|m| m.mark_type.as_str())
8622            .collect();
8623        assert_eq!(
8624            mark_types,
8625            vec!["underline", "link"],
8626            "mark order should be preserved, got: {mark_types:?}"
8627        );
8628    }
8629
8630    #[test]
8631    fn mark_ordering_textcolor_link_preserved() {
8632        // Issue #403: [textColor, link] order must be preserved
8633        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8634          {"type":"text","text":"red link","marks":[
8635            {"type":"textColor","attrs":{"color":"#ff0000"}},
8636            {"type":"link","attrs":{"href":"https://example.com"}}
8637          ]}
8638        ]}]}"##;
8639        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8640        let md = adf_to_markdown(&doc).unwrap();
8641        let round_tripped = markdown_to_adf(&md).unwrap();
8642        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
8643        let mark_types: Vec<&str> = node
8644            .marks
8645            .as_ref()
8646            .unwrap()
8647            .iter()
8648            .map(|m| m.mark_type.as_str())
8649            .collect();
8650        assert_eq!(
8651            mark_types,
8652            vec!["textColor", "link"],
8653            "mark order should be preserved, got: {mark_types:?}"
8654        );
8655    }
8656
8657    #[test]
8658    fn mark_ordering_link_underline_preserved() {
8659        // Issue #403: [link, underline] order must be preserved (link wraps bracketed span)
8660        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8661          {"type":"text","text":"click here","marks":[
8662            {"type":"link","attrs":{"href":"https://example.com"}},
8663            {"type":"underline"}
8664          ]}
8665        ]}]}"#;
8666        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8667        let md = adf_to_markdown(&doc).unwrap();
8668        // Link should wrap the underline bracketed span: [[click here]{underline}](url)
8669        assert!(
8670            md.contains("](https://example.com)"),
8671            "should have link: {md}"
8672        );
8673        assert!(md.contains("underline"), "should have underline: {md}");
8674        let round_tripped = markdown_to_adf(&md).unwrap();
8675        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
8676        let mark_types: Vec<&str> = node
8677            .marks
8678            .as_ref()
8679            .unwrap()
8680            .iter()
8681            .map(|m| m.mark_type.as_str())
8682            .collect();
8683        assert_eq!(
8684            mark_types,
8685            vec!["link", "underline"],
8686            "mark order should be preserved, got: {mark_types:?}"
8687        );
8688    }
8689
8690    #[test]
8691    fn mark_ordering_underline_strong_link_preserved() {
8692        // Issue #491: [underline, strong, link] reordered to [strong, underline, link]
8693        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8694          {"type":"text","text":"bold underlined link","marks":[
8695            {"type":"underline"},
8696            {"type":"strong"},
8697            {"type":"link","attrs":{"href":"https://example.com/page"}}
8698          ]}
8699        ]}]}"#;
8700        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8701        let md = adf_to_markdown(&doc).unwrap();
8702        let round_tripped = markdown_to_adf(&md).unwrap();
8703        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
8704        let mark_types: Vec<&str> = node
8705            .marks
8706            .as_ref()
8707            .unwrap()
8708            .iter()
8709            .map(|m| m.mark_type.as_str())
8710            .collect();
8711        assert_eq!(
8712            mark_types,
8713            vec!["underline", "strong", "link"],
8714            "mark order should be preserved, got: {mark_types:?}"
8715        );
8716    }
8717
8718    #[test]
8719    fn mark_ordering_strong_underline_link_preserved() {
8720        // Issue #491: verify [strong, underline, link] is preserved
8721        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8722          {"type":"text","text":"bold underlined link","marks":[
8723            {"type":"strong"},
8724            {"type":"underline"},
8725            {"type":"link","attrs":{"href":"https://example.com/page"}}
8726          ]}
8727        ]}]}"#;
8728        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8729        let md = adf_to_markdown(&doc).unwrap();
8730        let round_tripped = markdown_to_adf(&md).unwrap();
8731        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
8732        let mark_types: Vec<&str> = node
8733            .marks
8734            .as_ref()
8735            .unwrap()
8736            .iter()
8737            .map(|m| m.mark_type.as_str())
8738            .collect();
8739        assert_eq!(
8740            mark_types,
8741            vec!["strong", "underline", "link"],
8742            "mark order should be preserved, got: {mark_types:?}"
8743        );
8744    }
8745
8746    #[test]
8747    fn mark_ordering_underline_em_link_preserved() {
8748        // Issue #491: verify [underline, em, link] is preserved
8749        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8750          {"type":"text","text":"italic underlined link","marks":[
8751            {"type":"underline"},
8752            {"type":"em"},
8753            {"type":"link","attrs":{"href":"https://example.com/page"}}
8754          ]}
8755        ]}]}"#;
8756        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8757        let md = adf_to_markdown(&doc).unwrap();
8758        let round_tripped = markdown_to_adf(&md).unwrap();
8759        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
8760        let mark_types: Vec<&str> = node
8761            .marks
8762            .as_ref()
8763            .unwrap()
8764            .iter()
8765            .map(|m| m.mark_type.as_str())
8766            .collect();
8767        assert_eq!(
8768            mark_types,
8769            vec!["underline", "em", "link"],
8770            "mark order should be preserved, got: {mark_types:?}"
8771        );
8772    }
8773
8774    #[test]
8775    fn mark_ordering_underline_strike_link_preserved() {
8776        // Issue #491: verify [underline, strike, link] is preserved
8777        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8778          {"type":"text","text":"struck underlined link","marks":[
8779            {"type":"underline"},
8780            {"type":"strike"},
8781            {"type":"link","attrs":{"href":"https://example.com/page"}}
8782          ]}
8783        ]}]}"#;
8784        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8785        let md = adf_to_markdown(&doc).unwrap();
8786        let round_tripped = markdown_to_adf(&md).unwrap();
8787        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
8788        let mark_types: Vec<&str> = node
8789            .marks
8790            .as_ref()
8791            .unwrap()
8792            .iter()
8793            .map(|m| m.mark_type.as_str())
8794            .collect();
8795        assert_eq!(
8796            mark_types,
8797            vec!["underline", "strike", "link"],
8798            "mark order should be preserved, got: {mark_types:?}"
8799        );
8800    }
8801
8802    #[test]
8803    fn mark_ordering_underline_strong_em_link_preserved() {
8804        // Issue #491: verify four-mark combo [underline, strong, em, link] is preserved
8805        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8806          {"type":"text","text":"all the marks","marks":[
8807            {"type":"underline"},
8808            {"type":"strong"},
8809            {"type":"em"},
8810            {"type":"link","attrs":{"href":"https://example.com/page"}}
8811          ]}
8812        ]}]}"#;
8813        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8814        let md = adf_to_markdown(&doc).unwrap();
8815        let round_tripped = markdown_to_adf(&md).unwrap();
8816        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
8817        let mark_types: Vec<&str> = node
8818            .marks
8819            .as_ref()
8820            .unwrap()
8821            .iter()
8822            .map(|m| m.mark_type.as_str())
8823            .collect();
8824        assert_eq!(
8825            mark_types,
8826            vec!["underline", "strong", "em", "link"],
8827            "mark order should be preserved, got: {mark_types:?}"
8828        );
8829    }
8830
8831    #[test]
8832    fn em_strong_round_trip() {
8833        // Issue #401: em mark dropped when combined with strong
8834        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8835          {"type":"text","text":"bold and italic","marks":[{"type":"strong"},{"type":"em"}]}
8836        ]}]}"#;
8837        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8838        let md = adf_to_markdown(&doc).unwrap();
8839        assert_eq!(md.trim(), "***bold and italic***");
8840        let round_tripped = markdown_to_adf(&md).unwrap();
8841        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
8842        assert_eq!(node.text.as_deref(), Some("bold and italic"));
8843        let mark_types: Vec<&str> = node
8844            .marks
8845            .as_ref()
8846            .unwrap()
8847            .iter()
8848            .map(|m| m.mark_type.as_str())
8849            .collect();
8850        assert_eq!(
8851            mark_types,
8852            vec!["strong", "em"],
8853            "both strong and em marks should be preserved, got: {mark_types:?}"
8854        );
8855    }
8856
8857    #[test]
8858    fn em_strong_round_trip_em_first() {
8859        // Issue #401: em+strong with em listed first
8860        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8861          {"type":"text","text":"italic and bold","marks":[{"type":"em"},{"type":"strong"}]}
8862        ]}]}"#;
8863        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8864        let md = adf_to_markdown(&doc).unwrap();
8865        let round_tripped = markdown_to_adf(&md).unwrap();
8866        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
8867        assert_eq!(node.text.as_deref(), Some("italic and bold"));
8868        let mark_types: Vec<&str> = node
8869            .marks
8870            .as_ref()
8871            .unwrap()
8872            .iter()
8873            .map(|m| m.mark_type.as_str())
8874            .collect();
8875        assert!(
8876            mark_types.contains(&"strong") && mark_types.contains(&"em"),
8877            "both strong and em marks should be present, got: {mark_types:?}"
8878        );
8879    }
8880
8881    #[test]
8882    fn triple_asterisk_parse_to_adf() {
8883        // Issue #401: ***text*** should parse as text with strong+em marks
8884        let md = "***bold and italic***\n";
8885        let doc = markdown_to_adf(md).unwrap();
8886        let node = &doc.content[0].content.as_ref().unwrap()[0];
8887        assert_eq!(node.text.as_deref(), Some("bold and italic"));
8888        let mark_types: Vec<&str> = node
8889            .marks
8890            .as_ref()
8891            .unwrap()
8892            .iter()
8893            .map(|m| m.mark_type.as_str())
8894            .collect();
8895        assert!(
8896            mark_types.contains(&"strong") && mark_types.contains(&"em"),
8897            "***text*** should produce both strong and em marks, got: {mark_types:?}"
8898        );
8899    }
8900
8901    #[test]
8902    fn triple_asterisk_with_surrounding_text() {
8903        // Issue #401: surrounding text should not be affected
8904        let md = "before ***bold italic*** after\n";
8905        let doc = markdown_to_adf(md).unwrap();
8906        let nodes = doc.content[0].content.as_ref().unwrap();
8907        // Should have: "before " (plain), "bold italic" (strong+em), " after" (plain)
8908        assert!(
8909            nodes.len() >= 3,
8910            "expected at least 3 nodes, got {}",
8911            nodes.len()
8912        );
8913        assert_eq!(nodes[0].text.as_deref(), Some("before "));
8914        assert_eq!(nodes[1].text.as_deref(), Some("bold italic"));
8915        let mark_types: Vec<&str> = nodes[1]
8916            .marks
8917            .as_ref()
8918            .unwrap()
8919            .iter()
8920            .map(|m| m.mark_type.as_str())
8921            .collect();
8922        assert!(
8923            mark_types.contains(&"strong") && mark_types.contains(&"em"),
8924            "middle node should have strong+em, got: {mark_types:?}"
8925        );
8926        assert_eq!(nodes[2].text.as_deref(), Some(" after"));
8927    }
8928
8929    #[test]
8930    fn annotation_mark_round_trip() {
8931        // Issue #364: annotation marks were silently dropped
8932        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8933          {"type":"text","text":"highlighted text","marks":[
8934            {"type":"annotation","attrs":{"id":"abc123","annotationType":"inlineComment"}}
8935          ]}
8936        ]}]}"#;
8937        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8938
8939        let md = adf_to_markdown(&doc).unwrap();
8940        assert!(
8941            md.contains("annotation-id="),
8942            "JFM should contain annotation-id, got: {md}"
8943        );
8944
8945        let round_tripped = markdown_to_adf(&md).unwrap();
8946        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
8947        assert_eq!(text_node.text.as_deref(), Some("highlighted text"));
8948        let marks = text_node.marks.as_ref().expect("should have marks");
8949        let ann = marks
8950            .iter()
8951            .find(|m| m.mark_type == "annotation")
8952            .expect("should have annotation mark");
8953        let attrs = ann.attrs.as_ref().unwrap();
8954        assert_eq!(attrs["id"], "abc123");
8955        assert_eq!(attrs["annotationType"], "inlineComment");
8956    }
8957
8958    #[test]
8959    fn annotation_mark_with_bold() {
8960        // Annotation + bold should both survive round-trip
8961        let doc = AdfDocument {
8962            version: 1,
8963            doc_type: "doc".to_string(),
8964            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
8965                "bold comment",
8966                vec![
8967                    AdfMark::strong(),
8968                    AdfMark::annotation("def456", "inlineComment"),
8969                ],
8970            )])],
8971        };
8972        let md = adf_to_markdown(&doc).unwrap();
8973        let round_tripped = markdown_to_adf(&md).unwrap();
8974        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
8975        let marks = text_node.marks.as_ref().expect("should have marks");
8976        assert!(
8977            marks.iter().any(|m| m.mark_type == "strong"),
8978            "should have strong mark"
8979        );
8980        assert!(
8981            marks.iter().any(|m| m.mark_type == "annotation"),
8982            "should have annotation mark"
8983        );
8984    }
8985
8986    #[test]
8987    fn annotation_and_link_marks_both_preserved() {
8988        // Issue #390: text with both annotation and link marks loses link on round-trip
8989        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8990          {"type":"text","text":"HANGUL-8","marks":[
8991            {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"5ca7425e-34cd-48d3-b4eb-9873ac8b20e0"}},
8992            {"type":"link","attrs":{"href":"https://zendesk.atlassian.net/browse/HANGUL-8"}}
8993          ]}
8994        ]}]}"#;
8995        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8996        let md = adf_to_markdown(&doc).unwrap();
8997        // Should contain both annotation attrs and link syntax
8998        assert!(
8999            md.contains("annotation-id="),
9000            "JFM should contain annotation-id, got: {md}"
9001        );
9002        assert!(
9003            md.contains("](https://"),
9004            "JFM should contain link href, got: {md}"
9005        );
9006        let round_tripped = markdown_to_adf(&md).unwrap();
9007        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
9008        let marks = text_node.marks.as_ref().expect("should have marks");
9009        assert!(
9010            marks.iter().any(|m| m.mark_type == "annotation"),
9011            "should have annotation mark, got: {:?}",
9012            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
9013        );
9014        assert!(
9015            marks.iter().any(|m| m.mark_type == "link"),
9016            "should have link mark, got: {:?}",
9017            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
9018        );
9019    }
9020
9021    #[test]
9022    fn annotation_and_code_marks_both_preserved() {
9023        // Issue #508: annotation mark dropped when combined with code mark
9024        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
9025          {"type":"text","text":"some text with "},
9026          {"type":"text","text":"annotated code","marks":[
9027            {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"aabbccdd-1234-5678-abcd-000000000001"}},
9028            {"type":"code"}
9029          ]},
9030          {"type":"text","text":" remaining text"}
9031        ]}]}"#;
9032        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9033        let md = adf_to_markdown(&doc).unwrap();
9034        assert!(
9035            md.contains("annotation-id="),
9036            "JFM should contain annotation-id, got: {md}"
9037        );
9038        assert!(
9039            md.contains('`'),
9040            "JFM should contain backticks for code, got: {md}"
9041        );
9042
9043        let round_tripped = markdown_to_adf(&md).unwrap();
9044        let nodes = round_tripped.content[0].content.as_ref().unwrap();
9045        // Find the text node with "annotated code"
9046        let code_node = nodes
9047            .iter()
9048            .find(|n| n.text.as_deref() == Some("annotated code"))
9049            .expect("should have 'annotated code' text node");
9050        let marks = code_node.marks.as_ref().expect("should have marks");
9051        assert!(
9052            marks.iter().any(|m| m.mark_type == "annotation"),
9053            "should have annotation mark, got: {:?}",
9054            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
9055        );
9056        assert!(
9057            marks.iter().any(|m| m.mark_type == "code"),
9058            "should have code mark, got: {:?}",
9059            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
9060        );
9061        let ann = marks.iter().find(|m| m.mark_type == "annotation").unwrap();
9062        let attrs = ann.attrs.as_ref().unwrap();
9063        assert_eq!(attrs["id"], "aabbccdd-1234-5678-abcd-000000000001");
9064        assert_eq!(attrs["annotationType"], "inlineComment");
9065    }
9066
9067    #[test]
9068    fn annotation_and_code_and_link_marks_all_preserved() {
9069        // annotation + code + link should all survive round-trip
9070        let doc = AdfDocument {
9071            version: 1,
9072            doc_type: "doc".to_string(),
9073            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
9074                "linked code",
9075                vec![
9076                    AdfMark::annotation("ann-001", "inlineComment"),
9077                    AdfMark::code(),
9078                    AdfMark::link("https://example.com"),
9079                ],
9080            )])],
9081        };
9082        let md = adf_to_markdown(&doc).unwrap();
9083        assert!(
9084            md.contains("annotation-id="),
9085            "JFM should contain annotation-id, got: {md}"
9086        );
9087        assert!(md.contains('`'), "JFM should contain backticks, got: {md}");
9088        assert!(
9089            md.contains("](https://example.com)"),
9090            "JFM should contain link, got: {md}"
9091        );
9092
9093        let round_tripped = markdown_to_adf(&md).unwrap();
9094        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
9095        let marks = text_node.marks.as_ref().expect("should have marks");
9096        assert!(
9097            marks.iter().any(|m| m.mark_type == "annotation"),
9098            "should have annotation mark, got: {:?}",
9099            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
9100        );
9101        assert!(
9102            marks.iter().any(|m| m.mark_type == "code"),
9103            "should have code mark, got: {:?}",
9104            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
9105        );
9106        assert!(
9107            marks.iter().any(|m| m.mark_type == "link"),
9108            "should have link mark, got: {:?}",
9109            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
9110        );
9111    }
9112
9113    #[test]
9114    fn multiple_annotations_and_code_mark_preserved() {
9115        // Multiple annotation marks on a code node should all survive
9116        let doc = AdfDocument {
9117            version: 1,
9118            doc_type: "doc".to_string(),
9119            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
9120                "doubly annotated",
9121                vec![
9122                    AdfMark::annotation("ann-aaa", "inlineComment"),
9123                    AdfMark::annotation("ann-bbb", "inlineComment"),
9124                    AdfMark::code(),
9125                ],
9126            )])],
9127        };
9128        let md = adf_to_markdown(&doc).unwrap();
9129        assert!(
9130            md.contains("ann-aaa"),
9131            "JFM should contain first annotation id, got: {md}"
9132        );
9133        assert!(
9134            md.contains("ann-bbb"),
9135            "JFM should contain second annotation id, got: {md}"
9136        );
9137
9138        let round_tripped = markdown_to_adf(&md).unwrap();
9139        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
9140        let marks = text_node.marks.as_ref().expect("should have marks");
9141        let ann_marks: Vec<_> = marks
9142            .iter()
9143            .filter(|m| m.mark_type == "annotation")
9144            .collect();
9145        assert_eq!(
9146            ann_marks.len(),
9147            2,
9148            "should have 2 annotation marks, got: {}",
9149            ann_marks.len()
9150        );
9151        assert!(
9152            marks.iter().any(|m| m.mark_type == "code"),
9153            "should have code mark"
9154        );
9155    }
9156
9157    #[test]
9158    fn underline_and_link_marks_both_preserved() {
9159        // Underline + link should also coexist
9160        let doc = AdfDocument {
9161            version: 1,
9162            doc_type: "doc".to_string(),
9163            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
9164                "click here",
9165                vec![AdfMark::underline(), AdfMark::link("https://example.com")],
9166            )])],
9167        };
9168        let md = adf_to_markdown(&doc).unwrap();
9169        assert!(md.contains("underline"), "should have underline attr: {md}");
9170        assert!(
9171            md.contains("](https://example.com)"),
9172            "should have link: {md}"
9173        );
9174        let round_tripped = markdown_to_adf(&md).unwrap();
9175        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
9176        let marks = text_node.marks.as_ref().expect("should have marks");
9177        assert!(marks.iter().any(|m| m.mark_type == "underline"));
9178        assert!(marks.iter().any(|m| m.mark_type == "link"));
9179    }
9180
9181    #[test]
9182    fn annotation_link_and_bold_all_preserved() {
9183        // All three marks should coexist
9184        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
9185          {"type":"text","text":"important","marks":[
9186            {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"abc"}},
9187            {"type":"link","attrs":{"href":"https://example.com"}},
9188            {"type":"strong"}
9189          ]}
9190        ]}]}"#;
9191        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9192        let md = adf_to_markdown(&doc).unwrap();
9193        let round_tripped = markdown_to_adf(&md).unwrap();
9194        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
9195        let marks = text_node.marks.as_ref().expect("should have marks");
9196        assert!(
9197            marks.iter().any(|m| m.mark_type == "annotation"),
9198            "should have annotation"
9199        );
9200        assert!(
9201            marks.iter().any(|m| m.mark_type == "link"),
9202            "should have link"
9203        );
9204        assert!(
9205            marks.iter().any(|m| m.mark_type == "strong"),
9206            "should have strong"
9207        );
9208    }
9209
9210    #[test]
9211    fn multiple_annotation_marks_round_trip() {
9212        // Issue #439: multiple annotation marks on same text node — all but last dropped
9213        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
9214          {"type":"text","text":"some annotated text","marks":[
9215            {"type":"annotation","attrs":{"id":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","annotationType":"inlineComment"}},
9216            {"type":"annotation","attrs":{"id":"ffffffff-1111-2222-3333-444444444444","annotationType":"inlineComment"}}
9217          ]}
9218        ]}]}"#;
9219        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9220
9221        let md = adf_to_markdown(&doc).unwrap();
9222        assert!(
9223            md.contains("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"),
9224            "JFM should contain first annotation id, got: {md}"
9225        );
9226        assert!(
9227            md.contains("ffffffff-1111-2222-3333-444444444444"),
9228            "JFM should contain second annotation id, got: {md}"
9229        );
9230
9231        let round_tripped = markdown_to_adf(&md).unwrap();
9232        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
9233        assert_eq!(text_node.text.as_deref(), Some("some annotated text"));
9234        let marks = text_node.marks.as_ref().expect("should have marks");
9235        let annotations: Vec<_> = marks
9236            .iter()
9237            .filter(|m| m.mark_type == "annotation")
9238            .collect();
9239        assert_eq!(
9240            annotations.len(),
9241            2,
9242            "should have 2 annotation marks, got: {annotations:?}"
9243        );
9244        let ids: Vec<_> = annotations
9245            .iter()
9246            .map(|a| a.attrs.as_ref().unwrap()["id"].as_str().unwrap())
9247            .collect();
9248        assert!(ids.contains(&"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"));
9249        assert!(ids.contains(&"ffffffff-1111-2222-3333-444444444444"));
9250    }
9251
9252    #[test]
9253    fn three_annotation_marks_round_trip() {
9254        // Verify three overlapping annotations all survive
9255        let doc = AdfDocument {
9256            version: 1,
9257            doc_type: "doc".to_string(),
9258            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
9259                "triple annotated",
9260                vec![
9261                    AdfMark::annotation("id-1", "inlineComment"),
9262                    AdfMark::annotation("id-2", "inlineComment"),
9263                    AdfMark::annotation("id-3", "inlineComment"),
9264                ],
9265            )])],
9266        };
9267        let md = adf_to_markdown(&doc).unwrap();
9268        let round_tripped = markdown_to_adf(&md).unwrap();
9269        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
9270        let marks = text_node.marks.as_ref().expect("should have marks");
9271        let annotations: Vec<_> = marks
9272            .iter()
9273            .filter(|m| m.mark_type == "annotation")
9274            .collect();
9275        assert_eq!(
9276            annotations.len(),
9277            3,
9278            "should have 3 annotation marks, got: {annotations:?}"
9279        );
9280    }
9281
9282    #[test]
9283    fn multiple_annotations_with_bold_round_trip() {
9284        // Multiple annotations + bold should all survive
9285        let doc = AdfDocument {
9286            version: 1,
9287            doc_type: "doc".to_string(),
9288            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
9289                "bold double annotated",
9290                vec![
9291                    AdfMark::strong(),
9292                    AdfMark::annotation("ann-a", "inlineComment"),
9293                    AdfMark::annotation("ann-b", "inlineComment"),
9294                ],
9295            )])],
9296        };
9297        let md = adf_to_markdown(&doc).unwrap();
9298        let round_tripped = markdown_to_adf(&md).unwrap();
9299        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
9300        let marks = text_node.marks.as_ref().expect("should have marks");
9301        assert!(
9302            marks.iter().any(|m| m.mark_type == "strong"),
9303            "should have strong mark"
9304        );
9305        let annotations: Vec<_> = marks
9306            .iter()
9307            .filter(|m| m.mark_type == "annotation")
9308            .collect();
9309        assert_eq!(
9310            annotations.len(),
9311            2,
9312            "should have 2 annotation marks, got: {annotations:?}"
9313        );
9314    }
9315
9316    #[test]
9317    fn multiple_annotations_with_link_round_trip() {
9318        // Multiple annotations + link should all survive
9319        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
9320          {"type":"text","text":"linked text","marks":[
9321            {"type":"annotation","attrs":{"id":"ann-x","annotationType":"inlineComment"}},
9322            {"type":"annotation","attrs":{"id":"ann-y","annotationType":"inlineComment"}},
9323            {"type":"link","attrs":{"href":"https://example.com"}}
9324          ]}
9325        ]}]}"#;
9326        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9327        let md = adf_to_markdown(&doc).unwrap();
9328        let round_tripped = markdown_to_adf(&md).unwrap();
9329        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
9330        let marks = text_node.marks.as_ref().expect("should have marks");
9331        assert!(
9332            marks.iter().any(|m| m.mark_type == "link"),
9333            "should have link mark"
9334        );
9335        let annotations: Vec<_> = marks
9336            .iter()
9337            .filter(|m| m.mark_type == "annotation")
9338            .collect();
9339        assert_eq!(
9340            annotations.len(),
9341            2,
9342            "should have 2 annotation marks, got: {annotations:?}"
9343        );
9344    }
9345
9346    // ── Issue #471: annotation marks on non-text inline nodes ─────────
9347
9348    #[test]
9349    fn annotation_on_emoji_round_trip() {
9350        // Issue #471: annotation mark on emoji node should survive round-trip
9351        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
9352          {"type":"emoji","attrs":{"id":"1f4dd","shortName":":memo:","text":"📝"},"marks":[
9353            {"type":"annotation","attrs":{"id":"ccddee11-2233-4455-aabb-ccddee112233","annotationType":"inlineComment"}}
9354          ]},
9355          {"type":"text","text":" annotated text","marks":[
9356            {"type":"annotation","attrs":{"id":"ccddee11-2233-4455-aabb-ccddee112233","annotationType":"inlineComment"}}
9357          ]}
9358        ]}]}"#;
9359        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9360        let md = adf_to_markdown(&doc).unwrap();
9361        assert!(
9362            md.contains("annotation-id="),
9363            "JFM should contain annotation-id for emoji, got: {md}"
9364        );
9365
9366        let round_tripped = markdown_to_adf(&md).unwrap();
9367        let nodes = round_tripped.content[0].content.as_ref().unwrap();
9368
9369        // Emoji node should retain annotation mark
9370        let emoji_node = nodes.iter().find(|n| n.node_type == "emoji").unwrap();
9371        let emoji_marks = emoji_node.marks.as_ref().expect("emoji should have marks");
9372        assert!(
9373            emoji_marks.iter().any(|m| m.mark_type == "annotation"),
9374            "emoji should have annotation mark, got: {emoji_marks:?}"
9375        );
9376        let ann = emoji_marks
9377            .iter()
9378            .find(|m| m.mark_type == "annotation")
9379            .unwrap();
9380        assert_eq!(
9381            ann.attrs.as_ref().unwrap()["id"],
9382            "ccddee11-2233-4455-aabb-ccddee112233"
9383        );
9384
9385        // Text node should also retain annotation mark
9386        let text_node = nodes.iter().find(|n| n.node_type == "text").unwrap();
9387        let text_marks = text_node.marks.as_ref().expect("text should have marks");
9388        assert!(
9389            text_marks.iter().any(|m| m.mark_type == "annotation"),
9390            "text should have annotation mark"
9391        );
9392    }
9393
9394    #[test]
9395    fn annotation_on_status_round_trip() {
9396        let mut status = AdfNode::status("In Progress", "blue");
9397        status.marks = Some(vec![AdfMark::annotation("ann-status-1", "inlineComment")]);
9398
9399        let doc = AdfDocument {
9400            version: 1,
9401            doc_type: "doc".to_string(),
9402            content: vec![AdfNode::paragraph(vec![status])],
9403        };
9404        let md = adf_to_markdown(&doc).unwrap();
9405        assert!(
9406            md.contains("annotation-id="),
9407            "JFM should contain annotation-id for status, got: {md}"
9408        );
9409
9410        let round_tripped = markdown_to_adf(&md).unwrap();
9411        let nodes = round_tripped.content[0].content.as_ref().unwrap();
9412        let status_node = nodes.iter().find(|n| n.node_type == "status").unwrap();
9413        let marks = status_node
9414            .marks
9415            .as_ref()
9416            .expect("status should have marks");
9417        assert!(
9418            marks.iter().any(|m| m.mark_type == "annotation"),
9419            "status should have annotation mark, got: {marks:?}"
9420        );
9421    }
9422
9423    #[test]
9424    fn annotation_on_date_round_trip() {
9425        let mut date = AdfNode::date("1704067200000");
9426        date.marks = Some(vec![AdfMark::annotation("ann-date-1", "inlineComment")]);
9427
9428        let doc = AdfDocument {
9429            version: 1,
9430            doc_type: "doc".to_string(),
9431            content: vec![AdfNode::paragraph(vec![date])],
9432        };
9433        let md = adf_to_markdown(&doc).unwrap();
9434        assert!(
9435            md.contains("annotation-id="),
9436            "JFM should contain annotation-id for date, got: {md}"
9437        );
9438
9439        let round_tripped = markdown_to_adf(&md).unwrap();
9440        let nodes = round_tripped.content[0].content.as_ref().unwrap();
9441        let date_node = nodes.iter().find(|n| n.node_type == "date").unwrap();
9442        let marks = date_node.marks.as_ref().expect("date should have marks");
9443        assert!(
9444            marks.iter().any(|m| m.mark_type == "annotation"),
9445            "date should have annotation mark, got: {marks:?}"
9446        );
9447    }
9448
9449    #[test]
9450    fn annotation_on_mention_round_trip() {
9451        let mut mention = AdfNode::mention("user-123", "@Alice");
9452        mention.marks = Some(vec![AdfMark::annotation("ann-mention-1", "inlineComment")]);
9453
9454        let doc = AdfDocument {
9455            version: 1,
9456            doc_type: "doc".to_string(),
9457            content: vec![AdfNode::paragraph(vec![mention])],
9458        };
9459        let md = adf_to_markdown(&doc).unwrap();
9460        assert!(
9461            md.contains("annotation-id="),
9462            "JFM should contain annotation-id for mention, got: {md}"
9463        );
9464
9465        let round_tripped = markdown_to_adf(&md).unwrap();
9466        let nodes = round_tripped.content[0].content.as_ref().unwrap();
9467        let mention_node = nodes.iter().find(|n| n.node_type == "mention").unwrap();
9468        let marks = mention_node
9469            .marks
9470            .as_ref()
9471            .expect("mention should have marks");
9472        assert!(
9473            marks.iter().any(|m| m.mark_type == "annotation"),
9474            "mention should have annotation mark, got: {marks:?}"
9475        );
9476    }
9477
9478    #[test]
9479    fn annotation_on_inline_card_round_trip() {
9480        let mut card = AdfNode::inline_card("https://example.com");
9481        card.marks = Some(vec![AdfMark::annotation("ann-card-1", "inlineComment")]);
9482
9483        let doc = AdfDocument {
9484            version: 1,
9485            doc_type: "doc".to_string(),
9486            content: vec![AdfNode::paragraph(vec![card])],
9487        };
9488        let md = adf_to_markdown(&doc).unwrap();
9489        assert!(
9490            md.contains("annotation-id="),
9491            "JFM should contain annotation-id for inlineCard, got: {md}"
9492        );
9493
9494        let round_tripped = markdown_to_adf(&md).unwrap();
9495        let nodes = round_tripped.content[0].content.as_ref().unwrap();
9496        let card_node = nodes.iter().find(|n| n.node_type == "inlineCard").unwrap();
9497        let marks = card_node
9498            .marks
9499            .as_ref()
9500            .expect("inlineCard should have marks");
9501        assert!(
9502            marks.iter().any(|m| m.mark_type == "annotation"),
9503            "inlineCard should have annotation mark, got: {marks:?}"
9504        );
9505    }
9506
9507    #[test]
9508    fn annotation_on_placeholder_round_trip() {
9509        let mut placeholder = AdfNode::placeholder("Enter text here");
9510        placeholder.marks = Some(vec![AdfMark::annotation("ann-ph-1", "inlineComment")]);
9511
9512        let doc = AdfDocument {
9513            version: 1,
9514            doc_type: "doc".to_string(),
9515            content: vec![AdfNode::paragraph(vec![placeholder])],
9516        };
9517        let md = adf_to_markdown(&doc).unwrap();
9518        assert!(
9519            md.contains("annotation-id="),
9520            "JFM should contain annotation-id for placeholder, got: {md}"
9521        );
9522
9523        let round_tripped = markdown_to_adf(&md).unwrap();
9524        let nodes = round_tripped.content[0].content.as_ref().unwrap();
9525        let ph_node = nodes.iter().find(|n| n.node_type == "placeholder").unwrap();
9526        let marks = ph_node
9527            .marks
9528            .as_ref()
9529            .expect("placeholder should have marks");
9530        assert!(
9531            marks.iter().any(|m| m.mark_type == "annotation"),
9532            "placeholder should have annotation mark, got: {marks:?}"
9533        );
9534    }
9535
9536    #[test]
9537    fn multiple_annotations_on_emoji_round_trip() {
9538        // Multiple annotation marks on a single emoji node
9539        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
9540          {"type":"emoji","attrs":{"shortName":":fire:","text":"🔥"},"marks":[
9541            {"type":"annotation","attrs":{"id":"ann-1","annotationType":"inlineComment"}},
9542            {"type":"annotation","attrs":{"id":"ann-2","annotationType":"inlineComment"}}
9543          ]}
9544        ]}]}"#;
9545        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9546        let md = adf_to_markdown(&doc).unwrap();
9547
9548        let round_tripped = markdown_to_adf(&md).unwrap();
9549        let nodes = round_tripped.content[0].content.as_ref().unwrap();
9550        let emoji_node = nodes.iter().find(|n| n.node_type == "emoji").unwrap();
9551        let marks = emoji_node.marks.as_ref().expect("emoji should have marks");
9552        let annotations: Vec<_> = marks
9553            .iter()
9554            .filter(|m| m.mark_type == "annotation")
9555            .collect();
9556        assert_eq!(
9557            annotations.len(),
9558            2,
9559            "emoji should have 2 annotation marks, got: {annotations:?}"
9560        );
9561    }
9562
9563    #[test]
9564    fn emoji_without_annotation_unchanged() {
9565        // Ensure emoji nodes without annotation marks are not affected
9566        let doc = AdfDocument {
9567            version: 1,
9568            doc_type: "doc".to_string(),
9569            content: vec![AdfNode::paragraph(vec![AdfNode::emoji(":fire:")])],
9570        };
9571        let md = adf_to_markdown(&doc).unwrap();
9572        // Should NOT have bracketed span wrapping
9573        assert!(
9574            !md.contains('['),
9575            "emoji without annotation should not be wrapped in brackets, got: {md}"
9576        );
9577        assert!(md.contains(":fire:"));
9578    }
9579
9580    // ── Inline directive tests (Tier 4) ───────────────────────────────
9581
9582    #[test]
9583    fn status_directive() {
9584        let doc = markdown_to_adf("The ticket is :status[In Progress]{color=blue}.").unwrap();
9585        let content = doc.content[0].content.as_ref().unwrap();
9586        assert_eq!(content[1].node_type, "status");
9587        assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "In Progress");
9588        assert_eq!(content[1].attrs.as_ref().unwrap()["color"], "blue");
9589    }
9590
9591    #[test]
9592    fn adf_status_to_markdown() {
9593        let doc = AdfDocument {
9594            version: 1,
9595            doc_type: "doc".to_string(),
9596            content: vec![AdfNode::paragraph(vec![AdfNode::status("Done", "green")])],
9597        };
9598        let md = adf_to_markdown(&doc).unwrap();
9599        assert!(md.contains(":status[Done]{color=green}"));
9600    }
9601
9602    #[test]
9603    fn round_trip_status() {
9604        let md = "The ticket is :status[In Progress]{color=blue}.\n";
9605        let doc = markdown_to_adf(md).unwrap();
9606        let result = adf_to_markdown(&doc).unwrap();
9607        assert!(result.contains(":status[In Progress]{color=blue}"));
9608    }
9609
9610    #[test]
9611    fn status_with_style_and_localid_roundtrips() {
9612        let adf = AdfDocument {
9613            version: 1,
9614            doc_type: "doc".to_string(),
9615            content: vec![AdfNode::paragraph(vec![{
9616                let mut node = AdfNode::status("open", "green");
9617                node.attrs.as_mut().unwrap()["style"] =
9618                    serde_json::Value::String("bold".to_string());
9619                node.attrs.as_mut().unwrap()["localId"] =
9620                    serde_json::Value::String("d2205ca5-84b9-4950-a730-bfe550fc146b".to_string());
9621                node
9622            }])],
9623        };
9624
9625        let md = adf_to_markdown(&adf).unwrap();
9626        assert!(
9627            md.contains("style=bold"),
9628            "Markdown should contain style attr: {md}"
9629        );
9630        assert!(
9631            md.contains("localId=d2205ca5"),
9632            "Markdown should contain localId attr: {md}"
9633        );
9634
9635        let rt = markdown_to_adf(&md).unwrap();
9636        let status = &rt.content[0].content.as_ref().unwrap()[0];
9637        let attrs = status.attrs.as_ref().unwrap();
9638        assert_eq!(attrs["text"], "open");
9639        assert_eq!(attrs["color"], "green");
9640        assert_eq!(attrs["style"], "bold");
9641        assert_eq!(
9642            attrs["localId"], "d2205ca5-84b9-4950-a730-bfe550fc146b",
9643            "localId should be preserved, got: {}",
9644            attrs["localId"]
9645        );
9646    }
9647
9648    #[test]
9649    fn status_without_style_still_works() {
9650        let md = ":status[Done]{color=green}\n";
9651        let doc = markdown_to_adf(md).unwrap();
9652        let status = &doc.content[0].content.as_ref().unwrap()[0];
9653        let attrs = status.attrs.as_ref().unwrap();
9654        assert_eq!(attrs["text"], "Done");
9655        assert_eq!(attrs["color"], "green");
9656        // No style attr — should not be present
9657        assert!(
9658            attrs.get("style").is_none() || attrs["style"].is_null(),
9659            "style should not be set when not provided"
9660        );
9661    }
9662
9663    #[test]
9664    fn strip_local_ids_removes_localid_from_status() {
9665        let adf = AdfDocument {
9666            version: 1,
9667            doc_type: "doc".to_string(),
9668            content: vec![AdfNode::paragraph(vec![{
9669                let mut node = AdfNode::status("open", "green");
9670                node.attrs.as_mut().unwrap()["localId"] =
9671                    serde_json::Value::String("real-uuid-here".to_string());
9672                node
9673            }])],
9674        };
9675        let opts = RenderOptions {
9676            strip_local_ids: true,
9677        };
9678        let md = adf_to_markdown_with_options(&adf, &opts).unwrap();
9679        assert!(
9680            !md.contains("localId"),
9681            "localId should be stripped, got: {md}"
9682        );
9683        assert!(md.contains("color=green"), "color should be preserved");
9684    }
9685
9686    #[test]
9687    fn strip_local_ids_removes_localid_from_table() {
9688        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"layout":"default","localId":"table-uuid"},"content":[{"type":"tableRow","content":[{"type":"tableCell","content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]}]}]}"#;
9689        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9690        let opts = RenderOptions {
9691            strip_local_ids: true,
9692        };
9693        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
9694        assert!(
9695            !md.contains("localId"),
9696            "localId should be stripped from table, got: {md}"
9697        );
9698        assert!(md.contains("layout=default"), "layout should be preserved");
9699    }
9700
9701    #[test]
9702    fn default_options_preserve_localid() {
9703        let adf = AdfDocument {
9704            version: 1,
9705            doc_type: "doc".to_string(),
9706            content: vec![AdfNode::paragraph(vec![{
9707                let mut node = AdfNode::status("open", "green");
9708                node.attrs.as_mut().unwrap()["localId"] =
9709                    serde_json::Value::String("real-uuid-here".to_string());
9710                node
9711            }])],
9712        };
9713        let md = adf_to_markdown(&adf).unwrap();
9714        assert!(
9715            md.contains("localId=real-uuid-here"),
9716            "Default should preserve localId, got: {md}"
9717        );
9718    }
9719
9720    #[test]
9721    fn mention_localid_roundtrip() {
9722        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"mention","attrs":{"id":"user123","text":"@Alice","localId":"m-001"}}]}]}"#;
9723        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9724        let md = adf_to_markdown(&doc).unwrap();
9725        assert!(
9726            md.contains("localId=m-001"),
9727            "mention should have localId in md: {md}"
9728        );
9729        let rt = markdown_to_adf(&md).unwrap();
9730        let mention = &rt.content[0].content.as_ref().unwrap()[0];
9731        assert_eq!(mention.attrs.as_ref().unwrap()["localId"], "m-001");
9732    }
9733
9734    #[test]
9735    fn date_localid_roundtrip() {
9736        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
9737        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9738        let md = adf_to_markdown(&doc).unwrap();
9739        assert!(
9740            md.contains("localId=d-001"),
9741            "date should have localId in md: {md}"
9742        );
9743        let rt = markdown_to_adf(&md).unwrap();
9744        let date = &rt.content[0].content.as_ref().unwrap()[0];
9745        assert_eq!(date.attrs.as_ref().unwrap()["localId"], "d-001");
9746    }
9747
9748    #[test]
9749    fn emoji_localid_roundtrip() {
9750        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"emoji","attrs":{"shortName":":smile:","localId":"e-001"}}]}]}"#;
9751        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9752        let md = adf_to_markdown(&doc).unwrap();
9753        assert!(
9754            md.contains("localId=e-001"),
9755            "emoji should have localId in md: {md}"
9756        );
9757        let rt = markdown_to_adf(&md).unwrap();
9758        let emoji = &rt.content[0].content.as_ref().unwrap()[0];
9759        assert_eq!(emoji.attrs.as_ref().unwrap()["localId"], "e-001");
9760    }
9761
9762    #[test]
9763    fn inline_card_localid_roundtrip() {
9764        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"inlineCard","attrs":{"url":"https://example.com","localId":"c-001"}}]}]}"#;
9765        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9766        let md = adf_to_markdown(&doc).unwrap();
9767        assert!(
9768            md.contains("localId=c-001"),
9769            "inlineCard should have localId in md: {md}"
9770        );
9771        let rt = markdown_to_adf(&md).unwrap();
9772        let card = &rt.content[0].content.as_ref().unwrap()[0];
9773        assert_eq!(card.attrs.as_ref().unwrap()["localId"], "c-001");
9774    }
9775
9776    #[test]
9777    fn strip_local_ids_removes_from_mention() {
9778        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"mention","attrs":{"id":"user123","text":"@Alice","localId":"m-001"}}]}]}"#;
9779        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9780        let opts = RenderOptions {
9781            strip_local_ids: true,
9782        };
9783        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
9784        assert!(
9785            !md.contains("localId"),
9786            "localId should be stripped from mention: {md}"
9787        );
9788        assert!(md.contains("id=user123"), "other attrs should be preserved");
9789    }
9790
9791    #[test]
9792    fn strip_local_ids_removes_from_date() {
9793        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
9794        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9795        let opts = RenderOptions {
9796            strip_local_ids: true,
9797        };
9798        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
9799        assert!(
9800            !md.contains("localId"),
9801            "localId should be stripped from date: {md}"
9802        );
9803    }
9804
9805    #[test]
9806    fn strip_local_ids_removes_from_block_attrs() {
9807        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"p-001"},"content":[{"type":"text","text":"hello"}]}]}"#;
9808        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9809        let opts = RenderOptions {
9810            strip_local_ids: true,
9811        };
9812        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
9813        assert!(
9814            !md.contains("localId"),
9815            "localId should be stripped from block attrs: {md}"
9816        );
9817    }
9818
9819    #[test]
9820    fn table_cell_localid_roundtrip() {
9821        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"localId":"tc-001"},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]}]}]}"#;
9822        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9823        let md = adf_to_markdown(&doc).unwrap();
9824        assert!(
9825            md.contains("localId=tc-001"),
9826            "tableCell should have localId in md: {md}"
9827        );
9828        let rt = markdown_to_adf(&md).unwrap();
9829        let cell = &rt.content[0].content.as_ref().unwrap()[0]
9830            .content
9831            .as_ref()
9832            .unwrap()[0];
9833        assert_eq!(
9834            cell.attrs.as_ref().unwrap()["localId"],
9835            "tc-001",
9836            "tableCell localId should round-trip"
9837        );
9838    }
9839
9840    #[test]
9841    fn table_cell_border_mark_roundtrip() {
9842        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"marks":[{"type":"border","attrs":{"color":"#ff000033","size":2}}],"content":[{"type":"paragraph","content":[{"type":"text","text":"cell with border"}]}]}]}]}]}"##;
9843        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9844        let md = adf_to_markdown(&doc).unwrap();
9845        assert!(
9846            md.contains("border-color=#ff000033"),
9847            "tableCell should have border-color in md: {md}"
9848        );
9849        assert!(
9850            md.contains("border-size=2"),
9851            "tableCell should have border-size in md: {md}"
9852        );
9853        let rt = markdown_to_adf(&md).unwrap();
9854        let cell = &rt.content[0].content.as_ref().unwrap()[0]
9855            .content
9856            .as_ref()
9857            .unwrap()[0];
9858        let marks = cell.marks.as_ref().expect("tableCell should have marks");
9859        assert_eq!(marks.len(), 1);
9860        assert_eq!(marks[0].mark_type, "border");
9861        let attrs = marks[0].attrs.as_ref().unwrap();
9862        assert_eq!(attrs["color"], "#ff000033");
9863        assert_eq!(attrs["size"], 2);
9864    }
9865
9866    #[test]
9867    fn table_header_border_mark_roundtrip() {
9868        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableHeader","attrs":{},"marks":[{"type":"border","attrs":{"color":"#0000ff","size":3}}],"content":[{"type":"paragraph","content":[{"type":"text","text":"header"}]}]}]}]}]}"##;
9869        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9870        let md = adf_to_markdown(&doc).unwrap();
9871        assert!(md.contains("border-color=#0000ff"), "md: {md}");
9872        assert!(md.contains("border-size=3"), "md: {md}");
9873        let rt = markdown_to_adf(&md).unwrap();
9874        let cell = &rt.content[0].content.as_ref().unwrap()[0]
9875            .content
9876            .as_ref()
9877            .unwrap()[0];
9878        assert_eq!(cell.node_type, "tableHeader");
9879        let marks = cell.marks.as_ref().expect("tableHeader should have marks");
9880        assert_eq!(marks[0].mark_type, "border");
9881        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#0000ff");
9882        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
9883    }
9884
9885    #[test]
9886    fn table_cell_border_mark_with_attrs_roundtrip() {
9887        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"background":"#e6fcff","colspan":2},"marks":[{"type":"border","attrs":{"color":"#ff000033","size":1}}],"content":[{"type":"paragraph","content":[{"type":"text","text":"styled"}]}]}]}]}]}"##;
9888        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9889        let md = adf_to_markdown(&doc).unwrap();
9890        assert!(md.contains("bg=#e6fcff"), "md: {md}");
9891        assert!(md.contains("colspan=2"), "md: {md}");
9892        assert!(md.contains("border-color=#ff000033"), "md: {md}");
9893        let rt = markdown_to_adf(&md).unwrap();
9894        let cell = &rt.content[0].content.as_ref().unwrap()[0]
9895            .content
9896            .as_ref()
9897            .unwrap()[0];
9898        assert_eq!(cell.attrs.as_ref().unwrap()["background"], "#e6fcff");
9899        assert_eq!(cell.attrs.as_ref().unwrap()["colspan"], 2);
9900        let marks = cell.marks.as_ref().expect("should have marks");
9901        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff000033");
9902    }
9903
9904    #[test]
9905    fn table_cell_no_border_mark_unchanged() {
9906        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","content":[{"type":"paragraph","content":[{"type":"text","text":"plain"}]}]}]}]}]}"#;
9907        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9908        let md = adf_to_markdown(&doc).unwrap();
9909        assert!(
9910            !md.contains("border-color"),
9911            "no border attrs expected: {md}"
9912        );
9913        let rt = markdown_to_adf(&md).unwrap();
9914        let cell = &rt.content[0].content.as_ref().unwrap()[0]
9915            .content
9916            .as_ref()
9917            .unwrap()[0];
9918        assert!(cell.marks.is_none(), "no marks expected on plain cell");
9919    }
9920
9921    #[test]
9922    fn table_cell_border_size_only_defaults_color() {
9923        // border-size without border-color should still produce a border mark
9924        // with the default color
9925        let md = "::::table\n:::tr\n:::td{border-size=3}\ncell\n:::\n:::\n::::\n";
9926        let doc = markdown_to_adf(md).unwrap();
9927        let cell = &doc.content[0].content.as_ref().unwrap()[0]
9928            .content
9929            .as_ref()
9930            .unwrap()[0];
9931        let marks = cell.marks.as_ref().expect("should have border mark");
9932        assert_eq!(marks[0].mark_type, "border");
9933        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#000000");
9934        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
9935    }
9936
9937    #[test]
9938    fn table_cell_border_color_only_defaults_size() {
9939        // border-color without border-size should default size to 1
9940        let md = "::::table\n:::tr\n:::td{border-color=#ff0000}\ncell\n:::\n:::\n::::\n";
9941        let doc = markdown_to_adf(md).unwrap();
9942        let cell = &doc.content[0].content.as_ref().unwrap()[0]
9943            .content
9944            .as_ref()
9945            .unwrap()[0];
9946        let marks = cell.marks.as_ref().expect("should have border mark");
9947        assert_eq!(marks[0].mark_type, "border");
9948        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff0000");
9949        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 1);
9950    }
9951
9952    #[test]
9953    fn media_file_border_mark_roundtrip() {
9954        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"mediaSingle","attrs":{"layout":"center","width":400,"widthType":"pixel"},"content":[{"type":"media","attrs":{"id":"aabbccdd-1234-5678-abcd-aabbccdd1234","type":"file","collection":"contentId-123456","width":800,"height":600},"marks":[{"type":"border","attrs":{"color":"#091e4224","size":2}}]}]}]}"##;
9955        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9956        let md = adf_to_markdown(&doc).unwrap();
9957        assert!(
9958            md.contains("border-color=#091e4224"),
9959            "media should have border-color in md: {md}"
9960        );
9961        assert!(
9962            md.contains("border-size=2"),
9963            "media should have border-size in md: {md}"
9964        );
9965        let rt = markdown_to_adf(&md).unwrap();
9966        let media_single = &rt.content[0];
9967        let media = &media_single.content.as_ref().unwrap()[0];
9968        assert_eq!(media.node_type, "media");
9969        let marks = media.marks.as_ref().expect("media should have marks");
9970        assert_eq!(marks.len(), 1);
9971        assert_eq!(marks[0].mark_type, "border");
9972        let attrs = marks[0].attrs.as_ref().unwrap();
9973        assert_eq!(attrs["color"], "#091e4224");
9974        assert_eq!(attrs["size"], 2);
9975    }
9976
9977    #[test]
9978    fn media_external_border_mark_roundtrip() {
9979        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"mediaSingle","attrs":{"layout":"center"},"content":[{"type":"media","attrs":{"type":"external","url":"https://example.com/img.png"},"marks":[{"type":"border","attrs":{"color":"#ff0000","size":3}}]}]}]}"##;
9980        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9981        let md = adf_to_markdown(&doc).unwrap();
9982        assert!(
9983            md.contains("border-color=#ff0000"),
9984            "external media should have border-color in md: {md}"
9985        );
9986        assert!(
9987            md.contains("border-size=3"),
9988            "external media should have border-size in md: {md}"
9989        );
9990        let rt = markdown_to_adf(&md).unwrap();
9991        let media = &rt.content[0].content.as_ref().unwrap()[0];
9992        let marks = media.marks.as_ref().expect("media should have marks");
9993        assert_eq!(marks[0].mark_type, "border");
9994        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff0000");
9995        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
9996    }
9997
9998    #[test]
9999    fn media_file_no_border_mark_unchanged() {
10000        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"mediaSingle","attrs":{"layout":"center"},"content":[{"type":"media","attrs":{"id":"abc-123","type":"file","collection":"col-1","width":100,"height":100}}]}]}"#;
10001        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10002        let md = adf_to_markdown(&doc).unwrap();
10003        assert!(
10004            !md.contains("border-color"),
10005            "no border attrs expected: {md}"
10006        );
10007        let rt = markdown_to_adf(&md).unwrap();
10008        let media = &rt.content[0].content.as_ref().unwrap()[0];
10009        assert!(media.marks.is_none(), "no marks expected on plain media");
10010    }
10011
10012    #[test]
10013    fn media_border_size_only_defaults_color() {
10014        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"mediaSingle","attrs":{"layout":"center"},"content":[{"type":"media","attrs":{"id":"abc","type":"file","collection":"col"},"marks":[{"type":"border","attrs":{"size":4}}]}]}]}"##;
10015        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10016        let md = adf_to_markdown(&doc).unwrap();
10017        assert!(md.contains("border-size=4"), "md: {md}");
10018        let rt = markdown_to_adf(&md).unwrap();
10019        let media = &rt.content[0].content.as_ref().unwrap()[0];
10020        let marks = media.marks.as_ref().expect("should have border mark");
10021        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#000000");
10022        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 4);
10023    }
10024
10025    #[test]
10026    fn media_border_color_only_defaults_size() {
10027        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"mediaSingle","attrs":{"layout":"center"},"content":[{"type":"media","attrs":{"id":"abc","type":"file","collection":"col"},"marks":[{"type":"border","attrs":{"color":"#00ff00"}}]}]}]}"##;
10028        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10029        let md = adf_to_markdown(&doc).unwrap();
10030        assert!(md.contains("border-color=#00ff00"), "md: {md}");
10031        let rt = markdown_to_adf(&md).unwrap();
10032        let media = &rt.content[0].content.as_ref().unwrap()[0];
10033        let marks = media.marks.as_ref().expect("should have border mark");
10034        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#00ff00");
10035        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 1);
10036    }
10037
10038    #[test]
10039    fn media_border_with_other_attrs_roundtrip() {
10040        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"mediaSingle","attrs":{"layout":"wide","width":600,"widthType":"pixel"},"content":[{"type":"media","attrs":{"id":"xyz","type":"file","collection":"col","width":1200,"height":800},"marks":[{"type":"border","attrs":{"color":"#091e4224","size":2}}]}]}]}"##;
10041        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10042        let md = adf_to_markdown(&doc).unwrap();
10043        assert!(md.contains("layout=wide"), "md: {md}");
10044        assert!(md.contains("mediaWidth=600"), "md: {md}");
10045        assert!(md.contains("border-color=#091e4224"), "md: {md}");
10046        assert!(md.contains("border-size=2"), "md: {md}");
10047        let rt = markdown_to_adf(&md).unwrap();
10048        let ms = &rt.content[0];
10049        assert_eq!(ms.attrs.as_ref().unwrap()["layout"], "wide");
10050        let media = &ms.content.as_ref().unwrap()[0];
10051        let marks = media.marks.as_ref().expect("should have marks");
10052        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#091e4224");
10053        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 2);
10054    }
10055
10056    #[test]
10057    fn table_row_localid_roundtrip() {
10058        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{},"content":[{"type":"tableRow","attrs":{"localId":"tr-001"},"content":[{"type":"tableCell","content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]}]}]}"#;
10059        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10060        let md = adf_to_markdown(&doc).unwrap();
10061        assert!(
10062            md.contains("localId=tr-001"),
10063            "tableRow should have localId in md: {md}"
10064        );
10065        let rt = markdown_to_adf(&md).unwrap();
10066        let row = &rt.content[0].content.as_ref().unwrap()[0];
10067        assert_eq!(
10068            row.attrs.as_ref().unwrap()["localId"],
10069            "tr-001",
10070            "tableRow localId should round-trip"
10071        );
10072    }
10073
10074    #[test]
10075    fn list_item_localid_roundtrip() {
10076        // listItem localId is emitted as trailing inline attrs and parsed back
10077        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","attrs":{"localId":"li-001"},"content":[{"type":"paragraph","content":[{"type":"text","text":"item"}]}]}]}]}"#;
10078        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10079        let md = adf_to_markdown(&doc).unwrap();
10080        assert!(
10081            md.contains("localId=li-001"),
10082            "listItem should have localId in md: {md}"
10083        );
10084        // Verify localId is on the listItem, NOT promoted to bulletList
10085        let rt = markdown_to_adf(&md).unwrap();
10086        let list = &rt.content[0];
10087        assert!(
10088            list.attrs.is_none() || list.attrs.as_ref().unwrap().get("localId").is_none(),
10089            "bulletList should NOT have localId: {:?}",
10090            list.attrs
10091        );
10092        let item = &list.content.as_ref().unwrap()[0];
10093        assert_eq!(
10094            item.attrs.as_ref().unwrap()["localId"],
10095            "li-001",
10096            "listItem should have localId=li-001"
10097        );
10098    }
10099
10100    #[test]
10101    fn list_item_localid_not_promoted_to_parent() {
10102        // Verify localId stays on listItem and doesn't leak to parent list
10103        let md = "- item {localId=li-002}\n";
10104        let doc = markdown_to_adf(md).unwrap();
10105        let list = &doc.content[0];
10106        assert!(
10107            list.attrs.is_none(),
10108            "bulletList should have no attrs: {:?}",
10109            list.attrs
10110        );
10111        let item = &list.content.as_ref().unwrap()[0];
10112        assert_eq!(item.attrs.as_ref().unwrap()["localId"], "li-002");
10113    }
10114
10115    #[test]
10116    fn ordered_list_item_localid_roundtrip() {
10117        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","attrs":{"localId":"oli-001"},"content":[{"type":"paragraph","content":[{"type":"text","text":"first"}]}]}]}]}"#;
10118        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10119        let md = adf_to_markdown(&doc).unwrap();
10120        assert!(md.contains("localId=oli-001"), "md: {md}");
10121        let rt = markdown_to_adf(&md).unwrap();
10122        let item = &rt.content[0].content.as_ref().unwrap()[0];
10123        assert_eq!(item.attrs.as_ref().unwrap()["localId"], "oli-001");
10124    }
10125
10126    #[test]
10127    fn task_item_localid_roundtrip() {
10128        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":"tl-001"},"content":[{"type":"taskItem","attrs":{"localId":"ti-001","state":"TODO"},"content":[{"type":"paragraph","content":[{"type":"text","text":"task"}]}]}]}]}"#;
10129        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10130        let md = adf_to_markdown(&doc).unwrap();
10131        assert!(md.contains("localId=ti-001"), "md: {md}");
10132        let rt = markdown_to_adf(&md).unwrap();
10133        let item = &rt.content[0].content.as_ref().unwrap()[0];
10134        assert_eq!(item.attrs.as_ref().unwrap()["localId"], "ti-001");
10135    }
10136
10137    /// Issue #447: taskList with empty-string localId and taskItems with
10138    /// short numeric localIds must survive a full round-trip.
10139    #[test]
10140    fn task_list_short_localid_roundtrip() {
10141        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":""},"content":[{"type":"taskItem","attrs":{"localId":"42","state":"TODO"}},{"type":"taskItem","attrs":{"localId":"99","state":"DONE"},"content":[{"type":"text","text":"done task"}]}]}]}"#;
10142        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10143        let md = adf_to_markdown(&doc).unwrap();
10144        // Both taskItem localIds should appear in the markdown
10145        assert!(md.contains("localId=42"), "localId=42 missing: {md}");
10146        assert!(md.contains("localId=99"), "localId=99 missing: {md}");
10147        // Empty-string localId should NOT appear as {localId=}
10148        assert!(
10149            !md.contains("localId=}"),
10150            "empty localId should not be emitted: {md}"
10151        );
10152        let rt = markdown_to_adf(&md).unwrap();
10153        let task_list = &rt.content[0];
10154        assert_eq!(task_list.node_type, "taskList");
10155        // No spurious extra nodes from {localId=}
10156        assert_eq!(rt.content.len(), 1, "should be exactly one top-level node");
10157        let items = task_list.content.as_ref().unwrap();
10158        assert_eq!(items.len(), 2);
10159        // First taskItem: localId=42, state=TODO, no content
10160        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
10161        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
10162        assert!(
10163            items[0].content.is_none(),
10164            "empty taskItem should have no content: {:?}",
10165            items[0].content
10166        );
10167        // Second taskItem: localId=99, state=DONE, content with text
10168        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "99");
10169        assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
10170        let content = items[1].content.as_ref().unwrap();
10171        assert_eq!(content.len(), 1);
10172        assert_eq!(content[0].text.as_deref(), Some("done task"));
10173    }
10174
10175    /// Issue #507: numeric localId on taskItem with hardBreak must survive
10176    /// round-trip — the {localId=…} suffix lands on the continuation line
10177    /// and must still be extracted by the parser.
10178    #[test]
10179    fn task_item_numeric_localid_with_hardbreak_roundtrip() {
10180        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":""},"content":[{"type":"taskItem","attrs":{"localId":"42","state":"DONE"},"content":[{"type":"paragraph","content":[{"type":"text","text":"Engineering Onboarding Link","marks":[{"type":"link","attrs":{"href":"https://example.com/onboarding"}}]},{"type":"hardBreak"},{"type":"text","text":"(This has links to all the various useful tools!!)"}]}]}]}]}"#;
10181        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10182        let md = adf_to_markdown(&doc).unwrap();
10183        // localId must appear in the markdown output
10184        assert!(md.contains("localId=42"), "localId=42 missing: {md}");
10185        // Round-trip back to ADF
10186        let rt = markdown_to_adf(&md).unwrap();
10187        assert_eq!(rt.content.len(), 1, "exactly one top-level node");
10188        let task_list = &rt.content[0];
10189        assert_eq!(task_list.node_type, "taskList");
10190        let items = task_list.content.as_ref().unwrap();
10191        assert_eq!(items.len(), 1);
10192        // localId preserved
10193        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
10194        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DONE");
10195        // Content structure preserved: paragraph with link + hardBreak + text
10196        let para = &items[0].content.as_ref().unwrap()[0];
10197        assert_eq!(para.node_type, "paragraph");
10198        let inlines = para.content.as_ref().unwrap();
10199        assert_eq!(inlines[0].node_type, "text");
10200        assert_eq!(
10201            inlines[0].text.as_deref(),
10202            Some("Engineering Onboarding Link")
10203        );
10204        assert_eq!(inlines[1].node_type, "hardBreak");
10205        assert_eq!(inlines[2].node_type, "text");
10206        assert_eq!(
10207            inlines[2].text.as_deref(),
10208            Some("(This has links to all the various useful tools!!)")
10209        );
10210        // The {localId=…} must not appear as literal text in the ADF output
10211        let rt_json = serde_json::to_string(&rt).unwrap();
10212        assert!(
10213            !rt_json.contains("{localId="),
10214            "localId attr syntax should not leak into ADF text: {rt_json}"
10215        );
10216    }
10217
10218    /// Issue #507: multiple taskItems with hardBreaks and numeric localIds.
10219    #[test]
10220    fn task_item_multiple_hardbreak_localids_roundtrip() {
10221        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":""},"content":[{"type":"taskItem","attrs":{"localId":"42","state":"DONE"},"content":[{"type":"paragraph","content":[{"type":"text","text":"first line"},{"type":"hardBreak"},{"type":"text","text":"second line"}]}]},{"type":"taskItem","attrs":{"localId":"67","state":"TODO"},"content":[{"type":"paragraph","content":[{"type":"text","text":"alpha"},{"type":"hardBreak"},{"type":"text","text":"beta"}]}]}]}]}"#;
10222        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10223        let md = adf_to_markdown(&doc).unwrap();
10224        assert!(md.contains("localId=42"), "localId=42 missing: {md}");
10225        assert!(md.contains("localId=67"), "localId=67 missing: {md}");
10226        let rt = markdown_to_adf(&md).unwrap();
10227        let items = rt.content[0].content.as_ref().unwrap();
10228        assert_eq!(items.len(), 2);
10229        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
10230        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "67");
10231        // Verify hardBreak content structure for both items
10232        for item in items {
10233            let para = &item.content.as_ref().unwrap()[0];
10234            assert_eq!(para.node_type, "paragraph");
10235            let inlines = para.content.as_ref().unwrap();
10236            assert_eq!(inlines[1].node_type, "hardBreak");
10237        }
10238    }
10239
10240    /// Issue #521: sibling taskItems with numeric localIds and hardBreak —
10241    /// unwrapped inline content.  The hardBreak continuation line must be
10242    /// indented so it stays within the list item, and both localIds must
10243    /// survive the round-trip.
10244    #[test]
10245    fn task_item_sibling_localid_hardbreak_unwrapped_roundtrip() {
10246        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":""},"content":[{"type":"taskItem","attrs":{"localId":"42","state":"DONE"},"content":[{"type":"text","text":"link text","marks":[{"type":"link","attrs":{"href":"https://example.com/page"}}]},{"type":"hardBreak"},{"type":"text","text":"(parenthetical note after hard break)"}]},{"type":"taskItem","attrs":{"localId":"69","state":"DONE"},"content":[{"type":"text","text":"second task item"}]}]}]}"#;
10247        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10248        let md = adf_to_markdown(&doc).unwrap();
10249        // Continuation line must be indented
10250        assert!(
10251            md.contains("  (parenthetical"),
10252            "continuation line should be 2-space indented: {md}"
10253        );
10254        assert!(md.contains("localId=42"), "localId=42 missing: {md}");
10255        assert!(md.contains("localId=69"), "localId=69 missing: {md}");
10256        let rt = markdown_to_adf(&md).unwrap();
10257        // Must remain a single taskList with 2 items
10258        assert_eq!(
10259            rt.content.len(),
10260            1,
10261            "should be one taskList: {:#?}",
10262            rt.content
10263        );
10264        assert_eq!(rt.content[0].node_type, "taskList");
10265        let items = rt.content[0].content.as_ref().unwrap();
10266        assert_eq!(items.len(), 2, "should have 2 taskItems");
10267        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
10268        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "69");
10269        // Verify first item has hardBreak
10270        let first_content = items[0].content.as_ref().unwrap();
10271        assert!(
10272            first_content.iter().any(|n| n.node_type == "hardBreak"),
10273            "first item should contain hardBreak"
10274        );
10275        // Verify second item content
10276        let second_content = items[1].content.as_ref().unwrap();
10277        assert_eq!(second_content[0].node_type, "text");
10278        assert_eq!(
10279            second_content[0].text.as_deref().unwrap(),
10280            "second task item"
10281        );
10282    }
10283
10284    /// Issue #521: sibling taskItems with paragraph-wrapped content and
10285    /// hardBreak — localIds must not be swapped or lost.
10286    #[test]
10287    fn task_item_sibling_localid_hardbreak_paragraph_roundtrip() {
10288        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":""},"content":[{"type":"taskItem","attrs":{"localId":"42","state":"DONE"},"content":[{"type":"paragraph","content":[{"type":"text","text":"link text","marks":[{"type":"link","attrs":{"href":"https://example.com/page"}}]},{"type":"hardBreak"},{"type":"text","text":"(parenthetical note after hard break)"}]}]},{"type":"taskItem","attrs":{"localId":"69","state":"DONE"},"content":[{"type":"paragraph","content":[{"type":"text","text":"second task item"}]}]}]}]}"#;
10289        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10290        let md = adf_to_markdown(&doc).unwrap();
10291        let rt = markdown_to_adf(&md).unwrap();
10292        assert_eq!(
10293            rt.content.len(),
10294            1,
10295            "should be one taskList: {:#?}",
10296            rt.content
10297        );
10298        let items = rt.content[0].content.as_ref().unwrap();
10299        assert_eq!(items.len(), 2);
10300        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
10301        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "69");
10302    }
10303
10304    /// Issue #521: three sibling taskItems — the middle one has a hardBreak.
10305    /// Ensures localIds don't leak between adjacent items.
10306    #[test]
10307    fn task_item_three_siblings_middle_hardbreak_roundtrip() {
10308        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":""},"content":[{"type":"taskItem","attrs":{"localId":"10","state":"TODO"},"content":[{"type":"text","text":"first"}]},{"type":"taskItem","attrs":{"localId":"20","state":"DONE"},"content":[{"type":"text","text":"alpha"},{"type":"hardBreak"},{"type":"text","text":"beta"}]},{"type":"taskItem","attrs":{"localId":"30","state":"TODO"},"content":[{"type":"text","text":"third"}]}]}]}"#;
10309        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10310        let md = adf_to_markdown(&doc).unwrap();
10311        let rt = markdown_to_adf(&md).unwrap();
10312        assert_eq!(rt.content.len(), 1);
10313        let items = rt.content[0].content.as_ref().unwrap();
10314        assert_eq!(items.len(), 3);
10315        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "10");
10316        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "20");
10317        assert_eq!(items[2].attrs.as_ref().unwrap()["localId"], "30");
10318        // Middle item should have hardBreak
10319        let mid_content = items[1].content.as_ref().unwrap();
10320        assert!(mid_content.iter().any(|n| n.node_type == "hardBreak"));
10321    }
10322
10323    /// Issue #447: regression — taskList with empty localId must not inject
10324    /// a spurious paragraph.
10325    #[test]
10326    fn task_list_empty_localid_no_spurious_paragraph() {
10327        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":""},"content":[{"type":"taskItem","attrs":{"localId":"tsk-1","state":"DONE"},"content":[{"type":"text","text":"completed item"}]}]}]}"#;
10328        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10329        let md = adf_to_markdown(&doc).unwrap();
10330        assert!(
10331            !md.contains("{localId=}"),
10332            "empty localId should not be emitted: {md}"
10333        );
10334        let rt = markdown_to_adf(&md).unwrap();
10335        assert_eq!(
10336            rt.content.len(),
10337            1,
10338            "no spurious paragraph: {:#?}",
10339            rt.content
10340        );
10341        assert_eq!(rt.content[0].node_type, "taskList");
10342    }
10343
10344    /// Issue #447: taskList localId should be stripped when strip_local_ids is set.
10345    #[test]
10346    fn task_list_localid_stripped() {
10347        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":"tl-001"},"content":[{"type":"taskItem","attrs":{"localId":"ti-001","state":"TODO"},"content":[{"type":"paragraph","content":[{"type":"text","text":"task"}]}]}]}]}"#;
10348        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10349        let opts = RenderOptions {
10350            strip_local_ids: true,
10351        };
10352        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
10353        assert!(!md.contains("localId"), "localId should be stripped: {md}");
10354    }
10355
10356    /// Issue #447: taskItem with no content still emits localId.
10357    #[test]
10358    fn task_item_no_content_emits_localid() {
10359        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":"00000000-0000-0000-0000-000000000000"},"content":[{"type":"taskItem","attrs":{"localId":"abc","state":"TODO"}}]}]}"#;
10360        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10361        let md = adf_to_markdown(&doc).unwrap();
10362        assert!(
10363            md.contains("localId=abc"),
10364            "localId should be emitted even without content: {md}"
10365        );
10366        let rt = markdown_to_adf(&md).unwrap();
10367        let item = &rt.content[0].content.as_ref().unwrap()[0];
10368        assert_eq!(item.attrs.as_ref().unwrap()["localId"], "abc");
10369        assert!(item.content.is_none(), "should have no content");
10370    }
10371
10372    /// Issue #447: taskList localId roundtrips through block attrs.
10373    #[test]
10374    fn task_list_localid_roundtrip() {
10375        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":"tl-xyz"},"content":[{"type":"taskItem","attrs":{"localId":"ti-001","state":"TODO"},"content":[{"type":"paragraph","content":[{"type":"text","text":"task"}]}]}]}]}"#;
10376        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10377        let md = adf_to_markdown(&doc).unwrap();
10378        assert!(
10379            md.contains("localId=tl-xyz"),
10380            "taskList localId missing: {md}"
10381        );
10382        let rt = markdown_to_adf(&md).unwrap();
10383        assert_eq!(
10384            rt.content[0].attrs.as_ref().unwrap()["localId"],
10385            "tl-xyz",
10386            "taskList localId should survive round-trip"
10387        );
10388    }
10389
10390    /// Issue #478: taskItem with paragraph wrapper (no localId) preserves wrapper on round-trip.
10391    #[test]
10392    fn task_item_paragraph_wrapper_roundtrip_no_localid() {
10393        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":"tl-001"},"content":[{"type":"taskItem","attrs":{"localId":"ti-001","state":"TODO"},"content":[{"type":"paragraph","content":[{"type":"text","text":"A task with paragraph wrapper"}]}]}]}]}"#;
10394        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10395        let md = adf_to_markdown(&doc).unwrap();
10396        assert!(
10397            md.contains("paraLocalId=_"),
10398            "should emit paraLocalId=_ sentinel: {md}"
10399        );
10400        let rt = markdown_to_adf(&md).unwrap();
10401        let item = &rt.content[0].content.as_ref().unwrap()[0];
10402        let content = item.content.as_ref().unwrap();
10403        assert_eq!(content.len(), 1, "should have one child: {content:#?}");
10404        assert_eq!(
10405            content[0].node_type, "paragraph",
10406            "child should be a paragraph: {content:#?}"
10407        );
10408        let para_content = content[0].content.as_ref().unwrap();
10409        assert_eq!(
10410            para_content[0].text.as_deref(),
10411            Some("A task with paragraph wrapper")
10412        );
10413        // Paragraph should have no attrs (localId was absent in the original)
10414        assert!(
10415            content[0].attrs.is_none(),
10416            "paragraph should have no attrs: {:?}",
10417            content[0].attrs
10418        );
10419    }
10420
10421    /// Issue #478: taskItem with paragraph wrapper AND paraLocalId preserves both.
10422    #[test]
10423    fn task_item_paragraph_wrapper_roundtrip_with_localid() {
10424        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":"tl-001"},"content":[{"type":"taskItem","attrs":{"localId":"ti-001","state":"TODO"},"content":[{"type":"paragraph","attrs":{"localId":"p-001"},"content":[{"type":"text","text":"task with para id"}]}]}]}]}"#;
10425        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10426        let md = adf_to_markdown(&doc).unwrap();
10427        assert!(
10428            md.contains("paraLocalId=p-001"),
10429            "should emit paraLocalId=p-001: {md}"
10430        );
10431        let rt = markdown_to_adf(&md).unwrap();
10432        let item = &rt.content[0].content.as_ref().unwrap()[0];
10433        let content = item.content.as_ref().unwrap();
10434        assert_eq!(content[0].node_type, "paragraph");
10435        assert_eq!(
10436            content[0].attrs.as_ref().unwrap()["localId"],
10437            "p-001",
10438            "paragraph localId should be preserved"
10439        );
10440    }
10441
10442    /// Issue #478: taskItem WITHOUT paragraph wrapper (unwrapped inline) still round-trips correctly.
10443    #[test]
10444    fn task_item_unwrapped_inline_no_paragraph_on_roundtrip() {
10445        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":"tl-001"},"content":[{"type":"taskItem","attrs":{"localId":"ti-001","state":"TODO"},"content":[{"type":"text","text":"unwrapped task"}]}]}]}"#;
10446        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10447        let md = adf_to_markdown(&doc).unwrap();
10448        assert!(
10449            !md.contains("paraLocalId"),
10450            "should NOT emit paraLocalId for unwrapped inline: {md}"
10451        );
10452        let rt = markdown_to_adf(&md).unwrap();
10453        let item = &rt.content[0].content.as_ref().unwrap()[0];
10454        let content = item.content.as_ref().unwrap();
10455        assert_eq!(
10456            content[0].node_type, "text",
10457            "should remain unwrapped: {content:#?}"
10458        );
10459    }
10460
10461    /// Issue #478: DONE taskItem with paragraph wrapper round-trips.
10462    #[test]
10463    fn task_item_done_paragraph_wrapper_roundtrip() {
10464        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":"tl-001"},"content":[{"type":"taskItem","attrs":{"localId":"ti-001","state":"DONE"},"content":[{"type":"paragraph","content":[{"type":"text","text":"completed task"}]}]}]}]}"#;
10465        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10466        let md = adf_to_markdown(&doc).unwrap();
10467        assert!(md.contains("- [x]"), "should render as done: {md}");
10468        let rt = markdown_to_adf(&md).unwrap();
10469        let item = &rt.content[0].content.as_ref().unwrap()[0];
10470        assert_eq!(item.attrs.as_ref().unwrap()["state"], "DONE");
10471        let content = item.content.as_ref().unwrap();
10472        assert_eq!(content[0].node_type, "paragraph");
10473    }
10474
10475    /// Issue #478: mixed taskItems — some with paragraph wrapper, some without.
10476    #[test]
10477    fn task_item_mixed_paragraph_and_unwrapped_roundtrip() {
10478        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":"tl-001"},"content":[{"type":"taskItem","attrs":{"localId":"ti-001","state":"TODO"},"content":[{"type":"paragraph","content":[{"type":"text","text":"wrapped"}]}]},{"type":"taskItem","attrs":{"localId":"ti-002","state":"DONE"},"content":[{"type":"text","text":"unwrapped"}]}]}]}"#;
10479        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10480        let md = adf_to_markdown(&doc).unwrap();
10481        let rt = markdown_to_adf(&md).unwrap();
10482        let items = rt.content[0].content.as_ref().unwrap();
10483        assert_eq!(items.len(), 2);
10484        // First item: paragraph wrapper preserved
10485        let c1 = items[0].content.as_ref().unwrap();
10486        assert_eq!(
10487            c1[0].node_type, "paragraph",
10488            "first item should have paragraph wrapper"
10489        );
10490        // Second item: no paragraph wrapper
10491        let c2 = items[1].content.as_ref().unwrap();
10492        assert_eq!(
10493            c2[0].node_type, "text",
10494            "second item should remain unwrapped"
10495        );
10496    }
10497
10498    /// Issue #478: taskItem with paragraph wrapper containing marks round-trips.
10499    #[test]
10500    fn task_item_paragraph_wrapper_with_marks_roundtrip() {
10501        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":"tl-001"},"content":[{"type":"taskItem","attrs":{"localId":"ti-001","state":"TODO"},"content":[{"type":"paragraph","content":[{"type":"text","text":"bold "},{"type":"text","text":"text","marks":[{"type":"strong"}]}]}]}]}]}"#;
10502        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10503        let md = adf_to_markdown(&doc).unwrap();
10504        let rt = markdown_to_adf(&md).unwrap();
10505        let item = &rt.content[0].content.as_ref().unwrap()[0];
10506        let content = item.content.as_ref().unwrap();
10507        assert_eq!(content[0].node_type, "paragraph");
10508        let para_children = content[0].content.as_ref().unwrap();
10509        assert!(
10510            para_children.len() >= 2,
10511            "paragraph should contain multiple inline nodes"
10512        );
10513    }
10514
10515    /// Issue #478: strip_local_ids suppresses the paraLocalId=_ sentinel too.
10516    #[test]
10517    fn task_item_paragraph_wrapper_stripped_with_option() {
10518        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":"tl-001"},"content":[{"type":"taskItem","attrs":{"localId":"ti-001","state":"TODO"},"content":[{"type":"paragraph","content":[{"type":"text","text":"task"}]}]}]}]}"#;
10519        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10520        let opts = RenderOptions {
10521            strip_local_ids: true,
10522        };
10523        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
10524        assert!(
10525            !md.contains("paraLocalId"),
10526            "paraLocalId should be stripped: {md}"
10527        );
10528        assert!(
10529            !md.contains("localId"),
10530            "all localIds should be stripped: {md}"
10531        );
10532    }
10533
10534    #[test]
10535    fn trailing_space_preserved_with_hex_localid() {
10536        // Issue #449: trailing whitespace stripped from text node
10537        // when listItem has a hex-format localId (no hyphens)
10538        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","attrs":{"localId":"aabb112233cc"},"content":[{"type":"paragraph","content":[{"type":"text","text":"trailing space "}]}]}]}]}"#;
10539        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10540        let md = adf_to_markdown(&doc).unwrap();
10541        let rt = markdown_to_adf(&md).unwrap();
10542        let item = &rt.content[0].content.as_ref().unwrap()[0];
10543        assert_eq!(
10544            item.attrs.as_ref().unwrap()["localId"],
10545            "aabb112233cc",
10546            "localId should round-trip"
10547        );
10548        let para = &item.content.as_ref().unwrap()[0];
10549        let inlines = para.content.as_ref().unwrap();
10550        let last = inlines.last().unwrap();
10551        assert!(
10552            last.text.as_deref().unwrap_or("").ends_with(' '),
10553            "trailing space should be preserved, got nodes: {:?}",
10554            inlines
10555                .iter()
10556                .map(|n| (&n.node_type, &n.text))
10557                .collect::<Vec<_>>()
10558        );
10559    }
10560
10561    #[test]
10562    fn extract_trailing_local_id_preserves_trailing_space() {
10563        // Issue #449: only strip the single separator space before {localId=...}
10564        let (before, lid, _) = extract_trailing_local_id("trailing space  {localId=aabb112233cc}");
10565        assert_eq!(before, "trailing space ");
10566        assert_eq!(lid.as_deref(), Some("aabb112233cc"));
10567    }
10568
10569    #[test]
10570    fn extract_trailing_local_id_no_trailing_space() {
10571        let (before, lid, _) = extract_trailing_local_id("text {localId=abc123}");
10572        assert_eq!(before, "text");
10573        assert_eq!(lid.as_deref(), Some("abc123"));
10574    }
10575
10576    #[test]
10577    fn extract_trailing_local_id_no_attrs() {
10578        let (before, lid, pid) = extract_trailing_local_id("plain text");
10579        assert_eq!(before, "plain text");
10580        assert!(lid.is_none());
10581        assert!(pid.is_none());
10582    }
10583
10584    #[test]
10585    fn list_item_localid_stripped() {
10586        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","attrs":{"localId":"li-001"},"content":[{"type":"paragraph","content":[{"type":"text","text":"item"}]}]}]}]}"#;
10587        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10588        let opts = RenderOptions {
10589            strip_local_ids: true,
10590        };
10591        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
10592        assert!(!md.contains("localId"), "localId should be stripped: {md}");
10593    }
10594
10595    #[test]
10596    fn paragraph_localid_in_list_item_roundtrip() {
10597        // Issue #417: paragraph.attrs.localId dropped in listItem context
10598        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","attrs":{"localId":"list-001"},"content":[{"type":"listItem","attrs":{"localId":"item-001"},"content":[{"type":"paragraph","attrs":{"localId":"para-001"},"content":[{"type":"text","text":"item text"}]}]}]}]}"#;
10599        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10600        let md = adf_to_markdown(&doc).unwrap();
10601        assert!(
10602            md.contains("paraLocalId=para-001"),
10603            "paragraph localId should be in md: {md}"
10604        );
10605        let rt = markdown_to_adf(&md).unwrap();
10606        let item = &rt.content[0].content.as_ref().unwrap()[0];
10607        assert_eq!(
10608            item.attrs.as_ref().unwrap()["localId"],
10609            "item-001",
10610            "listItem localId should survive"
10611        );
10612        let para = &item.content.as_ref().unwrap()[0];
10613        assert_eq!(
10614            para.attrs.as_ref().unwrap()["localId"],
10615            "para-001",
10616            "paragraph localId should survive round-trip"
10617        );
10618    }
10619
10620    #[test]
10621    fn paragraph_localid_in_ordered_list_item_roundtrip() {
10622        // Issue #417: paragraph localId in ordered list
10623        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","attrs":{"localId":"oli-001"},"content":[{"type":"paragraph","attrs":{"localId":"op-001"},"content":[{"type":"text","text":"first"}]}]}]}]}"#;
10624        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10625        let md = adf_to_markdown(&doc).unwrap();
10626        assert!(md.contains("paraLocalId=op-001"), "md: {md}");
10627        let rt = markdown_to_adf(&md).unwrap();
10628        let item = &rt.content[0].content.as_ref().unwrap()[0];
10629        assert_eq!(item.attrs.as_ref().unwrap()["localId"], "oli-001");
10630        let para = &item.content.as_ref().unwrap()[0];
10631        assert_eq!(para.attrs.as_ref().unwrap()["localId"], "op-001");
10632    }
10633
10634    #[test]
10635    fn paragraph_localid_only_in_list_item() {
10636        // paragraph has localId but listItem does not
10637        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","attrs":{"localId":"para-only"},"content":[{"type":"text","text":"text"}]}]}]}]}"#;
10638        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10639        let md = adf_to_markdown(&doc).unwrap();
10640        assert!(
10641            md.contains("paraLocalId=para-only"),
10642            "paragraph localId should be emitted: {md}"
10643        );
10644        let rt = markdown_to_adf(&md).unwrap();
10645        let item = &rt.content[0].content.as_ref().unwrap()[0];
10646        assert!(item.attrs.is_none(), "listItem should have no attrs");
10647        let para = &item.content.as_ref().unwrap()[0];
10648        assert_eq!(para.attrs.as_ref().unwrap()["localId"], "para-only");
10649    }
10650
10651    #[test]
10652    fn paragraph_localid_in_table_header_roundtrip() {
10653        // Issue #417: paragraph.attrs.localId dropped in tableHeader context
10654        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableHeader","attrs":{},"content":[{"type":"paragraph","attrs":{"localId":"aaaa-aaaa"},"content":[{"type":"text","text":"hello"}]}]}]}]}]}"#;
10655        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10656        let md = adf_to_markdown(&doc).unwrap();
10657        // Should use directive form (not pipe table) to preserve paragraph localId
10658        assert!(
10659            md.contains("localId=aaaa-aaaa"),
10660            "paragraph localId should be in md: {md}"
10661        );
10662        let rt = markdown_to_adf(&md).unwrap();
10663        let cell = &rt.content[0].content.as_ref().unwrap()[0]
10664            .content
10665            .as_ref()
10666            .unwrap()[0];
10667        let para = &cell.content.as_ref().unwrap()[0];
10668        assert_eq!(
10669            para.attrs.as_ref().unwrap()["localId"],
10670            "aaaa-aaaa",
10671            "paragraph localId should survive round-trip in tableHeader"
10672        );
10673    }
10674
10675    #[test]
10676    fn paragraph_localid_in_table_cell_roundtrip() {
10677        // Issue #417: paragraph localId in tableCell forces directive table
10678        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableHeader","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"header"}]}]}]},{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","attrs":{"localId":"cell-para"},"content":[{"type":"text","text":"data"}]}]}]}]}]}"#;
10679        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10680        let md = adf_to_markdown(&doc).unwrap();
10681        assert!(
10682            md.contains("localId=cell-para"),
10683            "paragraph localId should be in md: {md}"
10684        );
10685        let rt = markdown_to_adf(&md).unwrap();
10686        // Data row -> cell -> paragraph
10687        let cell = &rt.content[0].content.as_ref().unwrap()[1]
10688            .content
10689            .as_ref()
10690            .unwrap()[0];
10691        let para = &cell.content.as_ref().unwrap()[0];
10692        assert_eq!(
10693            para.attrs.as_ref().unwrap()["localId"],
10694            "cell-para",
10695            "paragraph localId should survive round-trip in tableCell"
10696        );
10697    }
10698
10699    #[test]
10700    fn nbsp_paragraph_with_localid_roundtrip() {
10701        // Issue #417: nbsp paragraph localId emitted as text instead of attrs
10702        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"nbsp-para"},"content":[{"type":"text","text":"\u00a0"}]}]}"#;
10703        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10704        let md = adf_to_markdown(&doc).unwrap();
10705        assert!(
10706            md.contains("::paragraph["),
10707            "nbsp should use directive form: {md}"
10708        );
10709        assert!(
10710            md.contains("localId=nbsp-para"),
10711            "localId should be in directive: {md}"
10712        );
10713        let rt = markdown_to_adf(&md).unwrap();
10714        let para = &rt.content[0];
10715        assert_eq!(
10716            para.attrs.as_ref().unwrap()["localId"],
10717            "nbsp-para",
10718            "localId should survive round-trip"
10719        );
10720        let text = para.content.as_ref().unwrap()[0].text.as_ref().unwrap();
10721        assert_eq!(text, "\u{00a0}", "nbsp should survive");
10722    }
10723
10724    #[test]
10725    fn empty_paragraph_with_localid_roundtrip() {
10726        // Empty paragraph directive with localId
10727        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"empty-para"}}]}"#;
10728        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10729        let md = adf_to_markdown(&doc).unwrap();
10730        assert!(
10731            md.contains("::paragraph{localId=empty-para}"),
10732            "empty paragraph should include localId in directive: {md}"
10733        );
10734        let rt = markdown_to_adf(&md).unwrap();
10735        assert_eq!(
10736            rt.content[0].attrs.as_ref().unwrap()["localId"],
10737            "empty-para"
10738        );
10739    }
10740
10741    #[test]
10742    fn paragraph_localid_stripped_from_list_item() {
10743        // strip_local_ids should also strip paraLocalId
10744        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","attrs":{"localId":"li-001"},"content":[{"type":"paragraph","attrs":{"localId":"p-001"},"content":[{"type":"text","text":"item"}]}]}]}]}"#;
10745        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10746        let opts = RenderOptions {
10747            strip_local_ids: true,
10748        };
10749        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
10750        assert!(!md.contains("localId"), "localId should be stripped: {md}");
10751        assert!(
10752            !md.contains("paraLocalId"),
10753            "paraLocalId should be stripped: {md}"
10754        );
10755    }
10756
10757    #[test]
10758    fn date_directive() {
10759        let doc = markdown_to_adf("Due by :date[2026-04-15].").unwrap();
10760        let content = doc.content[0].content.as_ref().unwrap();
10761        assert_eq!(content[1].node_type, "date");
10762        // ISO date is converted to epoch milliseconds
10763        assert_eq!(
10764            content[1].attrs.as_ref().unwrap()["timestamp"],
10765            "1776211200000"
10766        );
10767    }
10768
10769    #[test]
10770    fn adf_date_to_markdown() {
10771        // ADF dates use epoch ms; renderer converts back to ISO with timestamp attr
10772        let doc = AdfDocument {
10773            version: 1,
10774            doc_type: "doc".to_string(),
10775            content: vec![AdfNode::paragraph(vec![AdfNode::date("1776211200000")])],
10776        };
10777        let md = adf_to_markdown(&doc).unwrap();
10778        assert!(md.contains(":date[2026-04-15]{timestamp=1776211200000}"));
10779    }
10780
10781    #[test]
10782    fn adf_date_iso_passthrough() {
10783        // If ADF already has ISO date (legacy), pass through
10784        let doc = AdfDocument {
10785            version: 1,
10786            doc_type: "doc".to_string(),
10787            content: vec![AdfNode::paragraph(vec![AdfNode::date("2026-04-15")])],
10788        };
10789        let md = adf_to_markdown(&doc).unwrap();
10790        assert!(md.contains(":date[2026-04-15]{timestamp=2026-04-15}"));
10791    }
10792
10793    #[test]
10794    fn round_trip_date() {
10795        let md = "Due by :date[2026-04-15].\n";
10796        let doc = markdown_to_adf(md).unwrap();
10797        let result = adf_to_markdown(&doc).unwrap();
10798        assert!(result.contains(":date[2026-04-15]"));
10799    }
10800
10801    #[test]
10802    fn round_trip_date_non_midnight_timestamp() {
10803        // Issue #409: non-midnight timestamps must survive round-trip
10804        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000"}}]}]}"#;
10805        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10806        let md = adf_to_markdown(&doc).unwrap();
10807        // JFM should include the original timestamp
10808        assert!(
10809            md.contains("timestamp=1700000000000"),
10810            "JFM should preserve original timestamp: {md}"
10811        );
10812        // Round-trip back to ADF
10813        let doc2 = markdown_to_adf(&md).unwrap();
10814        let content = doc2.content[0].content.as_ref().unwrap();
10815        assert_eq!(
10816            content[0].attrs.as_ref().unwrap()["timestamp"],
10817            "1700000000000",
10818            "Round-trip must preserve original non-midnight timestamp"
10819        );
10820    }
10821
10822    #[test]
10823    fn date_epoch_ms_passthrough() {
10824        // If JFM date is already epoch ms, pass through
10825        let doc = markdown_to_adf("Due by :date[1776211200000].").unwrap();
10826        let content = doc.content[0].content.as_ref().unwrap();
10827        assert_eq!(
10828            content[1].attrs.as_ref().unwrap()["timestamp"],
10829            "1776211200000"
10830        );
10831    }
10832
10833    #[test]
10834    fn date_timestamp_attr_preferred_over_content() {
10835        // When timestamp attr is present, it takes priority over the display date
10836        let md = ":date[2023-11-14]{timestamp=1700000000000}\n";
10837        let doc = markdown_to_adf(md).unwrap();
10838        let content = doc.content[0].content.as_ref().unwrap();
10839        assert_eq!(
10840            content[0].attrs.as_ref().unwrap()["timestamp"],
10841            "1700000000000",
10842            "timestamp attr should be used directly"
10843        );
10844    }
10845
10846    #[test]
10847    fn date_without_timestamp_attr_backward_compat() {
10848        // Legacy JFM without timestamp attr still works via iso_date_to_epoch_ms
10849        let md = ":date[2026-04-15]\n";
10850        let doc = markdown_to_adf(md).unwrap();
10851        let content = doc.content[0].content.as_ref().unwrap();
10852        assert_eq!(
10853            content[0].attrs.as_ref().unwrap()["timestamp"],
10854            "1776211200000",
10855            "Should fall back to computing timestamp from date string"
10856        );
10857    }
10858
10859    #[test]
10860    fn date_with_local_id_and_timestamp() {
10861        // Both localId and timestamp should round-trip
10862        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
10863        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10864        let md = adf_to_markdown(&doc).unwrap();
10865        assert!(
10866            md.contains("timestamp=1700000000000"),
10867            "Should contain timestamp: {md}"
10868        );
10869        assert!(md.contains("localId=d-001"), "Should contain localId: {md}");
10870        // Round-trip
10871        let doc2 = markdown_to_adf(&md).unwrap();
10872        let content = doc2.content[0].content.as_ref().unwrap();
10873        let attrs = content[0].attrs.as_ref().unwrap();
10874        assert_eq!(attrs["timestamp"], "1700000000000");
10875        assert_eq!(attrs["localId"], "d-001");
10876    }
10877
10878    #[test]
10879    fn mention_directive() {
10880        let doc = markdown_to_adf("Assigned to :mention[Alice]{id=abc123}.").unwrap();
10881        let content = doc.content[0].content.as_ref().unwrap();
10882        assert_eq!(content[1].node_type, "mention");
10883        assert_eq!(content[1].attrs.as_ref().unwrap()["id"], "abc123");
10884        assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "Alice");
10885    }
10886
10887    #[test]
10888    fn adf_mention_to_markdown() {
10889        let doc = AdfDocument {
10890            version: 1,
10891            doc_type: "doc".to_string(),
10892            content: vec![AdfNode::paragraph(vec![AdfNode::mention(
10893                "abc123", "Alice",
10894            )])],
10895        };
10896        let md = adf_to_markdown(&doc).unwrap();
10897        assert!(md.contains(":mention[Alice]{id=abc123}"));
10898    }
10899
10900    #[test]
10901    fn round_trip_mention() {
10902        let md = "Assigned to :mention[Alice]{id=abc123}.\n";
10903        let doc = markdown_to_adf(md).unwrap();
10904        let result = adf_to_markdown(&doc).unwrap();
10905        assert!(result.contains(":mention[Alice]{id=abc123}"));
10906    }
10907
10908    #[test]
10909    fn mention_with_empty_access_level_round_trips() {
10910        // Issue #363: accessLevel="" produces accessLevel= which failed to parse
10911        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10912          {"type":"mention","attrs":{"id":"61921b41c15977006af2b1d1","text":"@Javier Inchausti","accessLevel":""}}
10913        ]}]}"#;
10914        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10915
10916        let md = adf_to_markdown(&doc).unwrap();
10917        let round_tripped = markdown_to_adf(&md).unwrap();
10918        let mention = &round_tripped.content[0].content.as_ref().unwrap()[0];
10919        assert_eq!(
10920            mention.node_type, "mention",
10921            "mention with empty accessLevel was not parsed as mention, got: {}",
10922            mention.node_type
10923        );
10924    }
10925
10926    #[test]
10927    fn span_with_color() {
10928        let doc = markdown_to_adf("This is :span[red text]{color=#ff5630}.").unwrap();
10929        let content = doc.content[0].content.as_ref().unwrap();
10930        assert_eq!(content[1].node_type, "text");
10931        assert_eq!(content[1].text.as_deref(), Some("red text"));
10932        let marks = content[1].marks.as_ref().unwrap();
10933        assert_eq!(marks[0].mark_type, "textColor");
10934    }
10935
10936    #[test]
10937    fn adf_text_color_to_markdown() {
10938        let doc = AdfDocument {
10939            version: 1,
10940            doc_type: "doc".to_string(),
10941            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10942                "red text",
10943                vec![AdfMark::text_color("#ff5630")],
10944            )])],
10945        };
10946        let md = adf_to_markdown(&doc).unwrap();
10947        assert!(md.contains(":span[red text]{color=#ff5630}"));
10948    }
10949
10950    #[test]
10951    fn round_trip_span_color() {
10952        let md = "This is :span[red text]{color=#ff5630}.\n";
10953        let doc = markdown_to_adf(md).unwrap();
10954        let result = adf_to_markdown(&doc).unwrap();
10955        assert!(result.contains(":span[red text]{color=#ff5630}"));
10956    }
10957
10958    #[test]
10959    fn text_color_and_link_marks_both_preserved() {
10960        // Issue #405: text with both textColor and link marks loses link on round-trip
10961        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10962          {"type":"text","text":"red link","marks":[
10963            {"type":"link","attrs":{"href":"https://example.com"}},
10964            {"type":"textColor","attrs":{"color":"#ff0000"}}
10965          ]}
10966        ]}]}"##;
10967        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10968        let md = adf_to_markdown(&doc).unwrap();
10969        assert!(
10970            md.contains(":span[red link]{color=#ff0000}"),
10971            "JFM should contain span with color, got: {md}"
10972        );
10973        assert!(
10974            md.contains("](https://example.com)"),
10975            "JFM should contain link href, got: {md}"
10976        );
10977        // Full round-trip: both marks survive
10978        let rt = markdown_to_adf(&md).unwrap();
10979        let text_node = &rt.content[0].content.as_ref().unwrap()[0];
10980        let marks = text_node.marks.as_ref().expect("should have marks");
10981        assert!(
10982            marks.iter().any(|m| m.mark_type == "textColor"),
10983            "should have textColor mark, got: {:?}",
10984            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
10985        );
10986        assert!(
10987            marks.iter().any(|m| m.mark_type == "link"),
10988            "should have link mark, got: {:?}",
10989            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
10990        );
10991        // Verify attribute values survive
10992        let link_mark = marks.iter().find(|m| m.mark_type == "link").unwrap();
10993        assert_eq!(
10994            link_mark.attrs.as_ref().unwrap()["href"],
10995            "https://example.com"
10996        );
10997        let color_mark = marks.iter().find(|m| m.mark_type == "textColor").unwrap();
10998        assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#ff0000");
10999    }
11000
11001    #[test]
11002    fn bg_color_and_link_marks_both_preserved() {
11003        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11004          {"type":"text","text":"highlighted link","marks":[
11005            {"type":"link","attrs":{"href":"https://example.com"}},
11006            {"type":"backgroundColor","attrs":{"color":"#ffff00"}}
11007          ]}
11008        ]}]}"##;
11009        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11010        let md = adf_to_markdown(&doc).unwrap();
11011        assert!(md.contains("bg=#ffff00"), "should have bg color: {md}");
11012        assert!(
11013            md.contains("](https://example.com)"),
11014            "should have link: {md}"
11015        );
11016        let rt = markdown_to_adf(&md).unwrap();
11017        let text_node = &rt.content[0].content.as_ref().unwrap()[0];
11018        let marks = text_node.marks.as_ref().expect("should have marks");
11019        assert!(marks.iter().any(|m| m.mark_type == "backgroundColor"));
11020        assert!(marks.iter().any(|m| m.mark_type == "link"));
11021    }
11022
11023    #[test]
11024    fn text_color_link_and_strong_rendering() {
11025        // Verify textColor + link + strong renders all three formatting elements
11026        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11027          {"type":"text","text":"bold red link","marks":[
11028            {"type":"strong"},
11029            {"type":"link","attrs":{"href":"https://example.com"}},
11030            {"type":"textColor","attrs":{"color":"#ff0000"}}
11031          ]}
11032        ]}]}"##;
11033        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11034        let md = adf_to_markdown(&doc).unwrap();
11035        assert!(
11036            md.starts_with("**") && md.trim().ends_with("**"),
11037            "should have bold wrapping: {md}"
11038        );
11039        assert!(md.contains("color=#ff0000"), "should have color: {md}");
11040        assert!(
11041            md.contains("](https://example.com)"),
11042            "should have link: {md}"
11043        );
11044    }
11045
11046    #[test]
11047    fn subsup_and_link_marks_both_preserved() {
11048        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11049          {"type":"text","text":"note","marks":[
11050            {"type":"link","attrs":{"href":"https://example.com"}},
11051            {"type":"subsup","attrs":{"type":"sup"}}
11052          ]}
11053        ]}]}"#;
11054        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11055        let md = adf_to_markdown(&doc).unwrap();
11056        assert!(md.contains("sup"), "should have sup: {md}");
11057        assert!(
11058            md.contains("](https://example.com)"),
11059            "should have link: {md}"
11060        );
11061        let rt = markdown_to_adf(&md).unwrap();
11062        let text_node = &rt.content[0].content.as_ref().unwrap()[0];
11063        let marks = text_node.marks.as_ref().expect("should have marks");
11064        assert!(marks.iter().any(|m| m.mark_type == "subsup"));
11065        assert!(marks.iter().any(|m| m.mark_type == "link"));
11066    }
11067
11068    #[test]
11069    fn text_color_without_link_unchanged() {
11070        // Regression guard: textColor without link should still work
11071        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11072          {"type":"text","text":"just red","marks":[
11073            {"type":"textColor","attrs":{"color":"#ff0000"}}
11074          ]}
11075        ]}]}"##;
11076        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11077        let md = adf_to_markdown(&doc).unwrap();
11078        assert!(md.contains(":span[just red]{color=#ff0000}"), "md: {md}");
11079        assert!(!md.contains("](http"), "should NOT have link syntax: {md}");
11080    }
11081
11082    #[test]
11083    fn inline_extension_directive() {
11084        let doc =
11085            markdown_to_adf("See :extension[fallback]{type=com.app key=widget} here.").unwrap();
11086        let content = doc.content[0].content.as_ref().unwrap();
11087        assert_eq!(content[1].node_type, "inlineExtension");
11088        assert_eq!(
11089            content[1].attrs.as_ref().unwrap()["extensionType"],
11090            "com.app"
11091        );
11092        assert_eq!(content[1].attrs.as_ref().unwrap()["extensionKey"], "widget");
11093    }
11094
11095    #[test]
11096    fn adf_inline_extension_to_markdown() {
11097        let doc = AdfDocument {
11098            version: 1,
11099            doc_type: "doc".to_string(),
11100            content: vec![AdfNode::paragraph(vec![AdfNode::inline_extension(
11101                "com.app",
11102                "widget",
11103                Some("fallback"),
11104            )])],
11105        };
11106        let md = adf_to_markdown(&doc).unwrap();
11107        assert!(md.contains(":extension[fallback]{type=com.app key=widget}"));
11108    }
11109
11110    // ── Helper function tests ──────────────────────────────────────────
11111
11112    #[test]
11113    fn parse_ordered_list_marker_valid() {
11114        let result = parse_ordered_list_marker("1. Hello");
11115        assert_eq!(result, Some((1, "Hello")));
11116    }
11117
11118    #[test]
11119    fn parse_ordered_list_marker_high_number() {
11120        let result = parse_ordered_list_marker("42. Item");
11121        assert_eq!(result, Some((42, "Item")));
11122    }
11123
11124    #[test]
11125    fn parse_ordered_list_marker_not_a_list() {
11126        assert!(parse_ordered_list_marker("not a list").is_none());
11127        assert!(parse_ordered_list_marker("1.no space").is_none());
11128    }
11129
11130    #[test]
11131    fn is_list_start_various() {
11132        assert!(is_list_start("- item"));
11133        assert!(is_list_start("* item"));
11134        assert!(is_list_start("+ item"));
11135        assert!(is_list_start("1. item"));
11136        assert!(!is_list_start("not a list"));
11137    }
11138
11139    #[test]
11140    fn is_horizontal_rule_various() {
11141        assert!(is_horizontal_rule("---"));
11142        assert!(is_horizontal_rule("***"));
11143        assert!(is_horizontal_rule("___"));
11144        assert!(is_horizontal_rule("------"));
11145        assert!(!is_horizontal_rule("--"));
11146        assert!(!is_horizontal_rule("abc"));
11147    }
11148
11149    #[test]
11150    fn is_table_separator_valid() {
11151        assert!(is_table_separator("| --- | --- |"));
11152        assert!(is_table_separator("|:---:|:---|"));
11153        assert!(!is_table_separator("no pipes here"));
11154    }
11155
11156    #[test]
11157    fn parse_table_row_cells() {
11158        let cells = parse_table_row("| A | B | C |");
11159        assert_eq!(cells, vec!["A", "B", "C"]);
11160    }
11161
11162    #[test]
11163    fn parse_image_syntax_valid() {
11164        let result = parse_image_syntax("![alt](url)");
11165        assert_eq!(result, Some(("alt", "url")));
11166    }
11167
11168    #[test]
11169    fn parse_image_syntax_not_image() {
11170        assert!(parse_image_syntax("not an image").is_none());
11171    }
11172
11173    // ── find_closing_paren tests ────────────────────────────────────
11174
11175    #[test]
11176    fn find_closing_paren_simple() {
11177        assert_eq!(find_closing_paren("(hello)", 0), Some(6));
11178    }
11179
11180    #[test]
11181    fn find_closing_paren_nested() {
11182        assert_eq!(find_closing_paren("(a(b)c)", 0), Some(6));
11183    }
11184
11185    #[test]
11186    fn find_closing_paren_unmatched() {
11187        assert_eq!(find_closing_paren("(no close", 0), None);
11188    }
11189
11190    #[test]
11191    fn find_closing_paren_offset() {
11192        // Start scanning from the second '('
11193        assert_eq!(find_closing_paren("xx(inner)", 2), Some(8));
11194    }
11195
11196    // ── Parentheses-in-URL tests (issue #509) ──────────────────────
11197
11198    #[test]
11199    fn try_parse_link_url_with_parens() {
11200        let input = "[here](https://example.com/faq#access-(permissions)-rest)";
11201        let result = try_parse_link(input, 0);
11202        assert_eq!(
11203            result,
11204            Some((
11205                input.len(),
11206                "here",
11207                "https://example.com/faq#access-(permissions)-rest"
11208            ))
11209        );
11210    }
11211
11212    #[test]
11213    fn try_parse_link_url_no_parens() {
11214        let input = "[text](https://example.com)";
11215        let result = try_parse_link(input, 0);
11216        assert_eq!(result, Some((input.len(), "text", "https://example.com")));
11217    }
11218
11219    #[test]
11220    fn try_parse_link_url_with_multiple_nested_parens() {
11221        let input = "[x](http://en.wikipedia.org/wiki/Foo_(bar_(baz)))";
11222        let result = try_parse_link(input, 0);
11223        assert_eq!(
11224            result,
11225            Some((
11226                input.len(),
11227                "x",
11228                "http://en.wikipedia.org/wiki/Foo_(bar_(baz))"
11229            ))
11230        );
11231    }
11232
11233    #[test]
11234    fn parse_image_syntax_url_with_parens() {
11235        let result = parse_image_syntax("![alt](https://example.com/page_(1))");
11236        assert_eq!(result, Some(("alt", "https://example.com/page_(1)")));
11237    }
11238
11239    #[test]
11240    fn parse_image_syntax_url_no_parens() {
11241        let result = parse_image_syntax("![alt](https://example.com)");
11242        assert_eq!(result, Some(("alt", "https://example.com")));
11243    }
11244
11245    #[test]
11246    fn link_with_parens_round_trip() {
11247        let href = "https://example.com/faq#I-need-access-(permissions)-added-in-Monitor";
11248        let mut text_node = AdfNode::text("here");
11249        text_node.marks = Some(vec![AdfMark::link(href)]);
11250        let adf_input = AdfDocument {
11251            version: 1,
11252            doc_type: "doc".to_string(),
11253            content: vec![AdfNode::paragraph(vec![text_node])],
11254        };
11255
11256        let jfm = adf_to_markdown(&adf_input).unwrap();
11257        let adf_output = markdown_to_adf(&jfm).unwrap();
11258
11259        // Extract the href from the round-tripped ADF
11260        let para = &adf_output.content[0];
11261        let text_node = &para.content.as_ref().unwrap()[0];
11262        let mark = &text_node.marks.as_ref().unwrap()[0];
11263        let result_href = mark.attrs.as_ref().unwrap()["href"].as_str().unwrap();
11264
11265        assert_eq!(result_href, href);
11266    }
11267
11268    #[test]
11269    fn flush_plain_empty_range() {
11270        let mut nodes = Vec::new();
11271        flush_plain("hello", 3, 3, &mut nodes);
11272        assert!(nodes.is_empty());
11273    }
11274
11275    #[test]
11276    fn add_mark_to_unmarked_node() {
11277        let mut node = AdfNode::text("test");
11278        add_mark(&mut node, AdfMark::strong());
11279        assert_eq!(node.marks.as_ref().unwrap().len(), 1);
11280    }
11281
11282    #[test]
11283    fn add_mark_to_marked_node() {
11284        let mut node = AdfNode::text_with_marks("test", vec![AdfMark::strong()]);
11285        add_mark(&mut node, AdfMark::em());
11286        assert_eq!(node.marks.as_ref().unwrap().len(), 2);
11287    }
11288
11289    // ── Directive table tests ──────────────────────────────────────
11290
11291    #[test]
11292    fn directive_table_basic() {
11293        let md = "::::table\n:::tr\n:::th\nHeader 1\n:::\n:::th\nHeader 2\n:::\n:::\n:::tr\n:::td\nCell 1\n:::\n:::td\nCell 2\n:::\n:::\n::::\n";
11294        let doc = markdown_to_adf(md).unwrap();
11295        assert_eq!(doc.content[0].node_type, "table");
11296        let rows = doc.content[0].content.as_ref().unwrap();
11297        assert_eq!(rows.len(), 2);
11298        assert_eq!(
11299            rows[0].content.as_ref().unwrap()[0].node_type,
11300            "tableHeader"
11301        );
11302        assert_eq!(rows[1].content.as_ref().unwrap()[0].node_type, "tableCell");
11303    }
11304
11305    #[test]
11306    fn directive_table_with_block_content() {
11307        let md = "::::table\n:::tr\n:::td\nCell with list:\n\n- Item 1\n- Item 2\n:::\n:::td\nSimple cell\n:::\n:::\n::::\n";
11308        let doc = markdown_to_adf(md).unwrap();
11309        let rows = doc.content[0].content.as_ref().unwrap();
11310        let cell = &rows[0].content.as_ref().unwrap()[0];
11311        // Cell should have block content (paragraph + bullet list)
11312        let content = cell.content.as_ref().unwrap();
11313        assert!(content.len() >= 2);
11314        assert_eq!(content[1].node_type, "bulletList");
11315    }
11316
11317    #[test]
11318    fn directive_table_with_cell_attrs() {
11319        let md = "::::table\n:::tr\n:::td{colspan=2 bg=#DEEBFF}\nSpanning cell\n:::\n:::\n::::\n";
11320        let doc = markdown_to_adf(md).unwrap();
11321        let cell = &doc.content[0].content.as_ref().unwrap()[0]
11322            .content
11323            .as_ref()
11324            .unwrap()[0];
11325        let attrs = cell.attrs.as_ref().unwrap();
11326        assert_eq!(attrs["colspan"], 2);
11327        assert_eq!(attrs["background"], "#DEEBFF");
11328    }
11329
11330    #[test]
11331    fn directive_table_with_css_var_background() {
11332        let bg = "var(--ds-background-accent-gray-subtlest, var(--ds-background-accent-gray-subtlest, #F1F2F4))";
11333        let md = format!("::::table\n:::tr\n:::th{{bg=\"{bg}\"}}\nHeader\n:::\n:::\n::::\n");
11334        let doc = markdown_to_adf(&md).unwrap();
11335        let row = &doc.content[0].content.as_ref().unwrap()[0];
11336        let cells = row.content.as_ref().unwrap();
11337        assert_eq!(cells.len(), 1, "row must have at least one cell");
11338        let attrs = cells[0].attrs.as_ref().unwrap();
11339        assert_eq!(attrs["background"], bg);
11340    }
11341
11342    #[test]
11343    fn css_var_background_round_trips() {
11344        let bg = "var(--ds-background-accent-gray-subtlest, #F1F2F4)";
11345        let adf = AdfDocument {
11346            version: 1,
11347            doc_type: "doc".to_string(),
11348            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
11349                AdfNode::table_header_with_attrs(
11350                    vec![AdfNode::paragraph(vec![AdfNode::text("Header")])],
11351                    serde_json::json!({"background": bg}),
11352                ),
11353            ])])],
11354        };
11355        let md = adf_to_markdown(&adf).unwrap();
11356        assert!(
11357            md.contains(&format!("bg=\"{bg}\"")),
11358            "bg value must be quoted in markdown: {md}"
11359        );
11360
11361        let round_tripped = markdown_to_adf(&md).unwrap();
11362        let row = &round_tripped.content[0].content.as_ref().unwrap()[0];
11363        let cells = row.content.as_ref().unwrap();
11364        assert_eq!(cells.len(), 1, "round-tripped row must have one cell");
11365        let rt_attrs = cells[0].attrs.as_ref().unwrap();
11366        assert_eq!(rt_attrs["background"], bg);
11367    }
11368
11369    #[test]
11370    fn directive_table_with_table_attrs() {
11371        let md = "::::table{layout=wide numbered}\n:::tr\n:::td\nCell\n:::\n:::\n::::\n";
11372        let doc = markdown_to_adf(md).unwrap();
11373        let attrs = doc.content[0].attrs.as_ref().unwrap();
11374        assert_eq!(attrs["layout"], "wide");
11375        assert_eq!(attrs["isNumberColumnEnabled"], true);
11376    }
11377
11378    #[test]
11379    fn adf_table_with_block_content_renders_directive_form() {
11380        // Table with a bullet list in a cell → should render as ::::table directive
11381        let doc = AdfDocument {
11382            version: 1,
11383            doc_type: "doc".to_string(),
11384            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
11385                AdfNode::table_cell(vec![
11386                    AdfNode::paragraph(vec![AdfNode::text("Cell with list:")]),
11387                    AdfNode::bullet_list(vec![AdfNode::list_item(vec![AdfNode::paragraph(vec![
11388                        AdfNode::text("Item 1"),
11389                    ])])]),
11390                ]),
11391            ])])],
11392        };
11393        let md = adf_to_markdown(&doc).unwrap();
11394        assert!(md.contains("::::table"));
11395        assert!(md.contains(":::td"));
11396        assert!(md.contains("- Item 1"));
11397    }
11398
11399    #[test]
11400    fn adf_table_inline_only_renders_pipe_form() {
11401        // Table with only inline content → pipe table
11402        let doc = AdfDocument {
11403            version: 1,
11404            doc_type: "doc".to_string(),
11405            content: vec![AdfNode::table(vec![
11406                AdfNode::table_row(vec![
11407                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
11408                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
11409                ]),
11410                AdfNode::table_row(vec![
11411                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C1")])]),
11412                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
11413                ]),
11414            ])],
11415        };
11416        let md = adf_to_markdown(&doc).unwrap();
11417        assert!(md.contains("| H1 | H2 |"));
11418        assert!(!md.contains("::::table"));
11419    }
11420
11421    #[test]
11422    fn adf_table_header_outside_first_row_renders_directive() {
11423        let doc = AdfDocument {
11424            version: 1,
11425            doc_type: "doc".to_string(),
11426            content: vec![AdfNode::table(vec![
11427                AdfNode::table_row(vec![
11428                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H")])]),
11429                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C")])]),
11430                ]),
11431                AdfNode::table_row(vec![
11432                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
11433                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
11434                ]),
11435            ])],
11436        };
11437        let md = adf_to_markdown(&doc).unwrap();
11438        assert!(md.contains("::::table"));
11439        assert!(md.contains(":::th"));
11440    }
11441
11442    #[test]
11443    fn adf_table_cell_attrs_rendered() {
11444        let doc = AdfDocument {
11445            version: 1,
11446            doc_type: "doc".to_string(),
11447            content: vec![AdfNode::table(vec![
11448                AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
11449                    AdfNode::text("H"),
11450                ])])]),
11451                AdfNode::table_row(vec![AdfNode::table_cell_with_attrs(
11452                    vec![AdfNode::paragraph(vec![AdfNode::text("C")])],
11453                    serde_json::json!({"background": "#DEEBFF", "colspan": 2}),
11454                )]),
11455            ])],
11456        };
11457        let md = adf_to_markdown(&doc).unwrap();
11458        assert!(md.contains("{colspan=2 bg=#DEEBFF}"));
11459    }
11460
11461    // ── Pipe table cell attrs tests ────────────────────────────────
11462
11463    #[test]
11464    fn pipe_table_cell_attrs() {
11465        let md = "| H1 | H2 |\n|---|---|\n| {bg=#DEEBFF} highlighted | normal |\n";
11466        let doc = markdown_to_adf(md).unwrap();
11467        let rows = doc.content[0].content.as_ref().unwrap();
11468        let cell = &rows[1].content.as_ref().unwrap()[0];
11469        let attrs = cell.attrs.as_ref().unwrap();
11470        assert_eq!(attrs["background"], "#DEEBFF");
11471    }
11472
11473    #[test]
11474    fn pipe_table_cell_colspan() {
11475        let md = "| H1 | H2 |\n|---|---|\n| {colspan=2} spanning |\n";
11476        let doc = markdown_to_adf(md).unwrap();
11477        let rows = doc.content[0].content.as_ref().unwrap();
11478        let cell = &rows[1].content.as_ref().unwrap()[0];
11479        let attrs = cell.attrs.as_ref().unwrap();
11480        assert_eq!(attrs["colspan"], 2);
11481    }
11482
11483    #[test]
11484    fn trailing_space_after_mention_in_table_cell_preserved() {
11485        // Issue #372: trailing space after mention in table cell was dropped
11486        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[
11487          {"type":"mention","attrs":{"id":"aaa","text":"@Rob"}},
11488          {"type":"text","text":" "}
11489        ]}]}]}]}]}"#;
11490        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11491        let md = adf_to_markdown(&doc).unwrap();
11492        let round_tripped = markdown_to_adf(&md).unwrap();
11493        let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
11494            .content
11495            .as_ref()
11496            .unwrap()[0];
11497        let para = &cell.content.as_ref().unwrap()[0];
11498        let inlines = para.content.as_ref().unwrap();
11499        assert!(
11500            inlines.len() >= 2,
11501            "expected mention + text(' ') nodes, got {} nodes: {:?}",
11502            inlines.len(),
11503            inlines.iter().map(|n| &n.node_type).collect::<Vec<_>>()
11504        );
11505        assert_eq!(inlines[0].node_type, "mention");
11506        assert_eq!(inlines[1].node_type, "text");
11507        assert_eq!(inlines[1].text.as_deref(), Some(" "));
11508    }
11509
11510    // ── Column alignment tests ─────────────────────────────────────
11511
11512    #[test]
11513    fn pipe_table_column_alignment() {
11514        let md = "| Left | Center | Right |\n|:---|:---:|---:|\n| L | C | R |\n";
11515        let doc = markdown_to_adf(md).unwrap();
11516        let rows = doc.content[0].content.as_ref().unwrap();
11517        // Header row
11518        let h_cells = rows[0].content.as_ref().unwrap();
11519        // Left → no mark
11520        assert!(h_cells[0].content.as_ref().unwrap()[0].marks.is_none());
11521        // Center → alignment center
11522        let center_marks = h_cells[1].content.as_ref().unwrap()[0]
11523            .marks
11524            .as_ref()
11525            .unwrap();
11526        assert_eq!(center_marks[0].attrs.as_ref().unwrap()["align"], "center");
11527        // Right → alignment end
11528        let right_marks = h_cells[2].content.as_ref().unwrap()[0]
11529            .marks
11530            .as_ref()
11531            .unwrap();
11532        assert_eq!(right_marks[0].attrs.as_ref().unwrap()["align"], "end");
11533    }
11534
11535    #[test]
11536    fn adf_table_alignment_roundtrip() {
11537        let doc = AdfDocument {
11538            version: 1,
11539            doc_type: "doc".to_string(),
11540            content: vec![AdfNode::table(vec![
11541                AdfNode::table_row(vec![
11542                    AdfNode::table_header(vec![{
11543                        let mut p = AdfNode::paragraph(vec![AdfNode::text("Center")]);
11544                        p.marks = Some(vec![AdfMark::alignment("center")]);
11545                        p
11546                    }]),
11547                    AdfNode::table_header(vec![{
11548                        let mut p = AdfNode::paragraph(vec![AdfNode::text("Right")]);
11549                        p.marks = Some(vec![AdfMark::alignment("end")]);
11550                        p
11551                    }]),
11552                ]),
11553                AdfNode::table_row(vec![
11554                    AdfNode::table_cell(vec![{
11555                        let mut p = AdfNode::paragraph(vec![AdfNode::text("C")]);
11556                        p.marks = Some(vec![AdfMark::alignment("center")]);
11557                        p
11558                    }]),
11559                    AdfNode::table_cell(vec![{
11560                        let mut p = AdfNode::paragraph(vec![AdfNode::text("R")]);
11561                        p.marks = Some(vec![AdfMark::alignment("end")]);
11562                        p
11563                    }]),
11564                ]),
11565            ])],
11566        };
11567        let md = adf_to_markdown(&doc).unwrap();
11568        assert!(md.contains(":---:"));
11569        assert!(md.contains("---:"));
11570    }
11571
11572    // ── Panel custom attrs tests ───────────────────────────────────
11573
11574    #[test]
11575    fn panel_custom_attrs_round_trip() {
11576        let md = ":::panel{type=custom icon=\":star:\" color=\"#DEEBFF\"}\nContent\n:::\n";
11577        let doc = markdown_to_adf(md).unwrap();
11578        let panel = &doc.content[0];
11579        let attrs = panel.attrs.as_ref().unwrap();
11580        assert_eq!(attrs["panelType"], "custom");
11581        assert_eq!(attrs["panelIcon"], ":star:");
11582        assert_eq!(attrs["panelColor"], "#DEEBFF");
11583
11584        let result = adf_to_markdown(&doc).unwrap();
11585        assert!(result.contains("type=custom"));
11586        assert!(result.contains("icon="));
11587        assert!(result.contains("color="));
11588    }
11589
11590    // ── Block card with attrs tests ────────────────────────────────
11591
11592    #[test]
11593    fn block_card_with_layout() {
11594        let md = "::card[https://example.com]{layout=wide}\n";
11595        let doc = markdown_to_adf(md).unwrap();
11596        let attrs = doc.content[0].attrs.as_ref().unwrap();
11597        assert_eq!(attrs["layout"], "wide");
11598
11599        let result = adf_to_markdown(&doc).unwrap();
11600        assert!(result.contains("::card[https://example.com]{layout=wide}"));
11601    }
11602
11603    // ── Extension params test ──────────────────────────────────────
11604
11605    #[test]
11606    fn extension_with_params() {
11607        let md = r#"::extension{type=com.atlassian.macro key=jira-chart params='{"jql":"project=PROJ"}'}"#;
11608        let doc = markdown_to_adf(&format!("{md}\n")).unwrap();
11609        let attrs = doc.content[0].attrs.as_ref().unwrap();
11610        assert_eq!(attrs["parameters"]["jql"], "project=PROJ");
11611    }
11612
11613    #[test]
11614    fn leaf_extension_layout_preserved_in_roundtrip() {
11615        // Issue #381: layout attr on extension nodes was dropped
11616        let adf_json = r#"{"version":1,"type":"doc","content":[
11617          {"type":"extension","attrs":{"extensionType":"com.atlassian.confluence.macro.core","extensionKey":"toc","layout":"default","parameters":{}}}
11618        ]}"#;
11619        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11620        let md = adf_to_markdown(&doc).unwrap();
11621        assert!(
11622            md.contains("layout=default"),
11623            "JFM should contain layout=default, got: {md}"
11624        );
11625        let round_tripped = markdown_to_adf(&md).unwrap();
11626        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
11627        assert_eq!(attrs["layout"], "default", "layout should be preserved");
11628        assert_eq!(attrs["extensionKey"], "toc");
11629    }
11630
11631    #[test]
11632    fn bodied_extension_layout_preserved_in_roundtrip() {
11633        // Bodied extension with layout
11634        let adf_json = r#"{"version":1,"type":"doc","content":[
11635          {"type":"bodiedExtension","attrs":{"extensionType":"com.atlassian.macro","extensionKey":"expand","layout":"wide"},
11636           "content":[{"type":"paragraph","content":[{"type":"text","text":"inner"}]}]}
11637        ]}"#;
11638        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11639        let md = adf_to_markdown(&doc).unwrap();
11640        assert!(
11641            md.contains("layout=wide"),
11642            "JFM should contain layout=wide, got: {md}"
11643        );
11644        let round_tripped = markdown_to_adf(&md).unwrap();
11645        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
11646        assert_eq!(attrs["layout"], "wide", "layout should be preserved");
11647    }
11648
11649    #[test]
11650    fn bodied_extension_parameters_preserved_in_roundtrip() {
11651        // Issue #473: parameters block inside bodiedExtension.attrs was dropped
11652        let adf_json = r#"{"version":1,"type":"doc","content":[
11653          {"type":"bodiedExtension","attrs":{"extensionType":"com.atlassian.confluence.macro.core","extensionKey":"details","layout":"default","localId":"aabbccdd-1234","parameters":{"macroMetadata":{"macroId":{"value":"bbccddee-2345"},"schemaVersion":{"value":"1"},"title":"Page Properties"},"macroParams":{}}},
11654           "content":[{"type":"paragraph","content":[{"type":"text","text":"Content inside bodied extension"}]}]}
11655        ]}"#;
11656        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11657        let md = adf_to_markdown(&doc).unwrap();
11658        assert!(
11659            md.contains("params="),
11660            "JFM should contain params attribute, got: {md}"
11661        );
11662        let round_tripped = markdown_to_adf(&md).unwrap();
11663        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
11664        assert_eq!(
11665            attrs["parameters"]["macroMetadata"]["title"], "Page Properties",
11666            "parameters should be preserved in round-trip"
11667        );
11668        assert_eq!(attrs["extensionKey"], "details");
11669        assert_eq!(attrs["layout"], "default");
11670        assert_eq!(attrs["localId"], "aabbccdd-1234");
11671    }
11672
11673    #[test]
11674    fn bodied_extension_malformed_params_ignored() {
11675        // Malformed params JSON should be silently ignored, not crash
11676        let md = ":::extension{type=com.atlassian.macro key=details params='not-valid-json'}\nContent\n:::\n";
11677        let doc = markdown_to_adf(md).unwrap();
11678        let attrs = doc.content[0].attrs.as_ref().unwrap();
11679        assert_eq!(attrs["extensionKey"], "details");
11680        // parameters should be absent since the JSON was invalid
11681        assert!(attrs.get("parameters").is_none());
11682    }
11683
11684    #[test]
11685    fn leaf_extension_localid_preserved_in_roundtrip() {
11686        // Extension with both layout and localId
11687        let adf_json = r#"{"version":1,"type":"doc","content":[
11688          {"type":"extension","attrs":{"extensionType":"com.atlassian.macro","extensionKey":"toc","layout":"default","localId":"abc-123"}}
11689        ]}"#;
11690        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11691        let md = adf_to_markdown(&doc).unwrap();
11692        let round_tripped = markdown_to_adf(&md).unwrap();
11693        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
11694        assert_eq!(attrs["layout"], "default");
11695        assert_eq!(attrs["localId"], "abc-123");
11696    }
11697
11698    // ── Mention with userType test ─────────────────────────────────
11699
11700    #[test]
11701    fn mention_with_user_type() {
11702        let md = "Hi :mention[Alice]{id=abc123 userType=DEFAULT}.\n";
11703        let doc = markdown_to_adf(md).unwrap();
11704        let mention = &doc.content[0].content.as_ref().unwrap()[1];
11705        assert_eq!(mention.attrs.as_ref().unwrap()["userType"], "DEFAULT");
11706
11707        let result = adf_to_markdown(&doc).unwrap();
11708        assert!(result.contains("userType=DEFAULT"));
11709    }
11710
11711    // ── Colwidth tests ─────────────────────────────────────────────
11712
11713    #[test]
11714    fn directive_table_colwidth() {
11715        let md = "::::table\n:::tr\n:::td{colwidth=100,200}\nCell\n:::\n:::\n::::\n";
11716        let doc = markdown_to_adf(md).unwrap();
11717        let cell = &doc.content[0].content.as_ref().unwrap()[0]
11718            .content
11719            .as_ref()
11720            .unwrap()[0];
11721        let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
11722        assert_eq!(colwidth, &[serde_json::json!(100), serde_json::json!(200)]);
11723    }
11724
11725    #[test]
11726    fn directive_table_colwidth_float_roundtrip() {
11727        // Confluence returns colwidth as floats (e.g. 157.0, 863.0).
11728        // adf_to_markdown must preserve them so markdown_to_adf can restore them.
11729        let adf_doc = serde_json::json!({
11730            "type": "doc",
11731            "version": 1,
11732            "content": [{
11733                "type": "table",
11734                "content": [{
11735                    "type": "tableRow",
11736                    "content": [
11737                        {
11738                            "type": "tableHeader",
11739                            "attrs": { "colwidth": [157.0] },
11740                            "content": [{ "type": "paragraph" }]
11741                        },
11742                        {
11743                            "type": "tableHeader",
11744                            "attrs": { "colwidth": [863.0] },
11745                            "content": [{ "type": "paragraph" }]
11746                        }
11747                    ]
11748                }]
11749            }]
11750        });
11751        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
11752        let md = adf_to_markdown(&doc).unwrap();
11753        assert!(
11754            md.contains("colwidth=157.0"),
11755            "expected colwidth=157.0 in markdown, got: {md}"
11756        );
11757        assert!(
11758            md.contains("colwidth=863.0"),
11759            "expected colwidth=863.0 in markdown, got: {md}"
11760        );
11761        // Round-trip back to ADF
11762        let doc2 = markdown_to_adf(&md).unwrap();
11763        let row = &doc2.content[0].content.as_ref().unwrap()[0];
11764        let header1 = &row.content.as_ref().unwrap()[0];
11765        let header2 = &row.content.as_ref().unwrap()[1];
11766        assert_eq!(
11767            header1.attrs.as_ref().unwrap()["colwidth"]
11768                .as_array()
11769                .unwrap(),
11770            &[serde_json::json!(157.0)]
11771        );
11772        assert_eq!(
11773            header2.attrs.as_ref().unwrap()["colwidth"]
11774                .as_array()
11775                .unwrap(),
11776            &[serde_json::json!(863.0)]
11777        );
11778    }
11779
11780    #[test]
11781    fn colwidth_float_preserved_in_roundtrip() {
11782        // Issue #369: colwidth 254.0 was coerced to integer 254
11783        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableHeader","attrs":{"colwidth":[254.0,416.0]},"content":[{"type":"paragraph","content":[]}]}]}]}]}"#;
11784        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11785        let md = adf_to_markdown(&doc).unwrap();
11786        let round_tripped = markdown_to_adf(&md).unwrap();
11787        let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
11788            .content
11789            .as_ref()
11790            .unwrap()[0];
11791        let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
11792        assert_eq!(
11793            colwidth,
11794            &[serde_json::json!(254.0), serde_json::json!(416.0)],
11795            "colwidth should preserve float values"
11796        );
11797    }
11798
11799    #[test]
11800    fn colwidth_integer_preserved_in_roundtrip() {
11801        // Issue #459: colwidth integer values emitted as floats after round-trip
11802        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colspan":1,"colwidth":[150],"rowspan":1},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]}]}]}"#;
11803        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11804        let md = adf_to_markdown(&doc).unwrap();
11805        assert!(
11806            md.contains("colwidth=150"),
11807            "expected colwidth=150 (no decimal) in markdown, got: {md}"
11808        );
11809        assert!(
11810            !md.contains("colwidth=150.0"),
11811            "colwidth should not have .0 suffix for integers, got: {md}"
11812        );
11813        // Round-trip back to ADF
11814        let round_tripped = markdown_to_adf(&md).unwrap();
11815        let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
11816            .content
11817            .as_ref()
11818            .unwrap()[0];
11819        let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
11820        assert_eq!(
11821            colwidth,
11822            &[serde_json::json!(150)],
11823            "colwidth should preserve integer values"
11824        );
11825        // Verify JSON serialization uses integer, not float
11826        let json_output = serde_json::to_string(&round_tripped).unwrap();
11827        assert!(
11828            json_output.contains(r#""colwidth":[150]"#),
11829            "JSON should contain integer colwidth, got: {json_output}"
11830        );
11831    }
11832
11833    #[test]
11834    fn colwidth_mixed_int_and_float_roundtrip() {
11835        // Integer colwidth from standard ADF and float colwidth from Confluence
11836        // should each preserve their original type through round-trip.
11837        let int_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colwidth":[100,200]}}]}]}]}"#;
11838        let float_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colwidth":[100.0,200.0]}}]}]}]}"#;
11839
11840        // Integer input → integer output
11841        let int_doc: AdfDocument = serde_json::from_str(int_json).unwrap();
11842        let int_md = adf_to_markdown(&int_doc).unwrap();
11843        assert!(
11844            int_md.contains("colwidth=100,200"),
11845            "integer colwidth in md: {int_md}"
11846        );
11847        let int_rt = markdown_to_adf(&int_md).unwrap();
11848        let int_serial = serde_json::to_string(&int_rt).unwrap();
11849        assert!(
11850            int_serial.contains(r#""colwidth":[100,200]"#),
11851            "integer colwidth in JSON: {int_serial}"
11852        );
11853
11854        // Float input → float output
11855        let float_doc: AdfDocument = serde_json::from_str(float_json).unwrap();
11856        let float_md = adf_to_markdown(&float_doc).unwrap();
11857        assert!(
11858            float_md.contains("colwidth=100.0,200.0"),
11859            "float colwidth in md: {float_md}"
11860        );
11861        let float_rt = markdown_to_adf(&float_md).unwrap();
11862        let float_serial = serde_json::to_string(&float_rt).unwrap();
11863        assert!(
11864            float_serial.contains(r#""colwidth":[100.0,200.0]"#),
11865            "float colwidth in JSON: {float_serial}"
11866        );
11867    }
11868
11869    #[test]
11870    fn colwidth_fractional_float_preserved() {
11871        // Covers the fractional-float branch (n.fract() != 0.0) in build_cell_attrs_string
11872        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colwidth":[100.5]},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]}]}]}"#;
11873        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11874        let md = adf_to_markdown(&doc).unwrap();
11875        assert!(
11876            md.contains("colwidth=100.5"),
11877            "expected colwidth=100.5 in markdown, got: {md}"
11878        );
11879    }
11880
11881    #[test]
11882    fn colwidth_non_numeric_values_skipped() {
11883        // Covers the None branch for non-numeric colwidth entries in build_cell_attrs_string
11884        let adf_doc = serde_json::json!({
11885            "type": "doc",
11886            "version": 1,
11887            "content": [{
11888                "type": "table",
11889                "content": [{
11890                    "type": "tableRow",
11891                    "content": [{
11892                        "type": "tableCell",
11893                        "attrs": { "colwidth": ["invalid"] },
11894                        "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "cell" }] }]
11895                    }]
11896                }]
11897            }]
11898        });
11899        let doc: AdfDocument = serde_json::from_value(adf_doc).unwrap();
11900        let md = adf_to_markdown(&doc).unwrap();
11901        // Non-numeric values are filtered out, so colwidth should not appear
11902        assert!(
11903            !md.contains("colwidth"),
11904            "non-numeric colwidth should be filtered out, got: {md}"
11905        );
11906    }
11907
11908    #[test]
11909    fn default_rowspan_colspan_preserved_in_roundtrip() {
11910        // Issue #369: rowspan=1 and colspan=1 were elided
11911        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"rowspan":1,"colspan":1},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]}]}]}"#;
11912        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11913        let md = adf_to_markdown(&doc).unwrap();
11914        let round_tripped = markdown_to_adf(&md).unwrap();
11915        let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
11916            .content
11917            .as_ref()
11918            .unwrap()[0];
11919        let attrs = cell.attrs.as_ref().unwrap();
11920        assert_eq!(attrs["rowspan"], 1, "rowspan=1 should be preserved");
11921        assert_eq!(attrs["colspan"], 1, "colspan=1 should be preserved");
11922    }
11923
11924    // ── Nested list tests ──────────────────────────────────────────────
11925
11926    #[test]
11927    fn table_localid_preserved_in_roundtrip() {
11928        // Issue #374: localId on table nodes was dropped
11929        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default","localId":"7afd4550-e66c-4b12-875f-a91c6c7b62c7"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]}]}]}"#;
11930        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11931        let md = adf_to_markdown(&doc).unwrap();
11932        assert!(
11933            md.contains("localId="),
11934            "JFM should contain localId, got: {md}"
11935        );
11936        let round_tripped = markdown_to_adf(&md).unwrap();
11937        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
11938        assert_eq!(
11939            attrs["localId"], "7afd4550-e66c-4b12-875f-a91c6c7b62c7",
11940            "localId should be preserved"
11941        );
11942    }
11943
11944    #[test]
11945    fn paragraph_localid_preserved_in_roundtrip() {
11946        // Issue #399: localId on paragraph nodes was dropped
11947        let adf_json = r#"{"version":1,"type":"doc","content":[
11948          {"type":"paragraph","attrs":{"localId":"abc-123"},"content":[{"type":"text","text":"hello"}]}
11949        ]}"#;
11950        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11951        let md = adf_to_markdown(&doc).unwrap();
11952        assert!(
11953            md.contains("localId=abc-123"),
11954            "JFM should contain localId, got: {md}"
11955        );
11956        let round_tripped = markdown_to_adf(&md).unwrap();
11957        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
11958        assert_eq!(attrs["localId"], "abc-123", "localId should be preserved");
11959    }
11960
11961    #[test]
11962    fn heading_localid_preserved_in_roundtrip() {
11963        let adf_json = r#"{"version":1,"type":"doc","content":[
11964          {"type":"heading","attrs":{"level":2,"localId":"h-456"},"content":[{"type":"text","text":"Title"}]}
11965        ]}"#;
11966        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11967        let md = adf_to_markdown(&doc).unwrap();
11968        let round_tripped = markdown_to_adf(&md).unwrap();
11969        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
11970        assert_eq!(attrs["localId"], "h-456");
11971    }
11972
11973    #[test]
11974    fn localid_with_alignment_preserved() {
11975        // localId and alignment marks should coexist in the same {attrs} block
11976        let adf_json = r#"{"version":1,"type":"doc","content":[
11977          {"type":"paragraph","attrs":{"localId":"p-789"},"marks":[{"type":"alignment","attrs":{"align":"center"}}],
11978           "content":[{"type":"text","text":"centered"}]}
11979        ]}"#;
11980        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11981        let md = adf_to_markdown(&doc).unwrap();
11982        assert!(md.contains("localId=p-789"), "should have localId: {md}");
11983        assert!(md.contains("align=center"), "should have align: {md}");
11984        let round_tripped = markdown_to_adf(&md).unwrap();
11985        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
11986        assert_eq!(attrs["localId"], "p-789");
11987        let marks = round_tripped.content[0].marks.as_ref().unwrap();
11988        assert!(marks.iter().any(|m| m.mark_type == "alignment"));
11989    }
11990
11991    #[test]
11992    fn table_layout_default_preserved_in_roundtrip() {
11993        // Issue #380: layout='default' was elided
11994        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]}]}]}"#;
11995        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11996        let md = adf_to_markdown(&doc).unwrap();
11997        let round_tripped = markdown_to_adf(&md).unwrap();
11998        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
11999        assert_eq!(
12000            attrs["layout"], "default",
12001            "layout='default' should be preserved"
12002        );
12003    }
12004
12005    #[test]
12006    fn table_is_number_column_enabled_false_preserved() {
12007        // Issue #380: isNumberColumnEnabled=false was elided
12008        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]}]}]}"#;
12009        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12010        let md = adf_to_markdown(&doc).unwrap();
12011        let round_tripped = markdown_to_adf(&md).unwrap();
12012        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
12013        assert_eq!(
12014            attrs["isNumberColumnEnabled"], false,
12015            "isNumberColumnEnabled=false should be preserved"
12016        );
12017    }
12018
12019    #[test]
12020    fn table_is_number_column_enabled_true_preserved() {
12021        // Regression check: isNumberColumnEnabled=true should still work
12022        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":true,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]}]}]}"#;
12023        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12024        let md = adf_to_markdown(&doc).unwrap();
12025        let round_tripped = markdown_to_adf(&md).unwrap();
12026        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
12027        assert_eq!(
12028            attrs["isNumberColumnEnabled"], true,
12029            "isNumberColumnEnabled=true should be preserved"
12030        );
12031    }
12032
12033    #[test]
12034    fn directive_table_is_number_column_enabled_false_preserved() {
12035        // Covers render_directive_table + directive table parsing for numbered=false.
12036        // Multi-paragraph cell forces directive table form.
12037        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[
12038          {"type":"paragraph","content":[{"type":"text","text":"line one"}]},
12039          {"type":"paragraph","content":[{"type":"text","text":"line two"}]}
12040        ]}]}]}]}"#;
12041        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12042        let md = adf_to_markdown(&doc).unwrap();
12043        assert!(md.contains("::::table"), "should use directive table form");
12044        assert!(
12045            md.contains("numbered=false"),
12046            "should contain numbered=false, got: {md}"
12047        );
12048        let round_tripped = markdown_to_adf(&md).unwrap();
12049        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
12050        assert_eq!(attrs["isNumberColumnEnabled"], false);
12051        assert_eq!(attrs["layout"], "default");
12052    }
12053
12054    #[test]
12055    fn directive_table_is_number_column_enabled_true_preserved() {
12056        // Covers render_directive_table + directive table parsing for numbered (true).
12057        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":true,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[
12058          {"type":"paragraph","content":[{"type":"text","text":"line one"}]},
12059          {"type":"paragraph","content":[{"type":"text","text":"line two"}]}
12060        ]}]}]}]}"#;
12061        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12062        let md = adf_to_markdown(&doc).unwrap();
12063        assert!(md.contains("::::table"), "should use directive table form");
12064        assert!(
12065            md.contains("numbered}") || md.contains("numbered "),
12066            "should contain numbered flag, got: {md}"
12067        );
12068        let round_tripped = markdown_to_adf(&md).unwrap();
12069        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
12070        assert_eq!(attrs["isNumberColumnEnabled"], true);
12071    }
12072
12073    #[test]
12074    fn trailing_space_in_bullet_list_item_preserved() {
12075        // Issue #394: trailing space text node in list item dropped
12076        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
12077          {"type":"listItem","content":[{"type":"paragraph","content":[
12078            {"type":"text","text":"Before link "},
12079            {"type":"text","text":"link text","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
12080            {"type":"text","text":" "}
12081          ]}]}
12082        ]}]}"#;
12083        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12084        let md = adf_to_markdown(&doc).unwrap();
12085        let round_tripped = markdown_to_adf(&md).unwrap();
12086        let list = &round_tripped.content[0];
12087        let item = &list.content.as_ref().unwrap()[0];
12088        let para = &item.content.as_ref().unwrap()[0];
12089        let inlines = para.content.as_ref().unwrap();
12090        let last = inlines.last().unwrap();
12091        assert_eq!(
12092            last.text.as_deref(),
12093            Some(" "),
12094            "trailing space text node should be preserved, got nodes: {:?}",
12095            inlines
12096                .iter()
12097                .map(|n| (&n.node_type, &n.text))
12098                .collect::<Vec<_>>()
12099        );
12100    }
12101
12102    #[test]
12103    fn trailing_space_after_mention_in_bullet_list_preserved() {
12104        // Mention + trailing space in list item
12105        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
12106          {"type":"listItem","content":[{"type":"paragraph","content":[
12107            {"type":"mention","attrs":{"id":"abc","text":"@Alice"}},
12108            {"type":"text","text":" "}
12109          ]}]}
12110        ]}]}"#;
12111        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12112        let md = adf_to_markdown(&doc).unwrap();
12113        let round_tripped = markdown_to_adf(&md).unwrap();
12114        let para = &round_tripped.content[0].content.as_ref().unwrap()[0]
12115            .content
12116            .as_ref()
12117            .unwrap()[0];
12118        let inlines = para.content.as_ref().unwrap();
12119        assert!(
12120            inlines.len() >= 2,
12121            "should have mention + trailing space, got {} nodes",
12122            inlines.len()
12123        );
12124        assert_eq!(inlines.last().unwrap().text.as_deref(), Some(" "));
12125    }
12126
12127    #[test]
12128    fn trailing_space_in_ordered_list_item_preserved() {
12129        // Same issue in ordered list context
12130        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
12131          {"type":"listItem","content":[{"type":"paragraph","content":[
12132            {"type":"text","text":"item "},
12133            {"type":"text","text":"link","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
12134            {"type":"text","text":" "}
12135          ]}]}
12136        ]}]}"#;
12137        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12138        let md = adf_to_markdown(&doc).unwrap();
12139        let round_tripped = markdown_to_adf(&md).unwrap();
12140        let para = &round_tripped.content[0].content.as_ref().unwrap()[0]
12141            .content
12142            .as_ref()
12143            .unwrap()[0];
12144        let inlines = para.content.as_ref().unwrap();
12145        let last = inlines.last().unwrap();
12146        assert_eq!(
12147            last.text.as_deref(),
12148            Some(" "),
12149            "trailing space should be preserved in ordered list item"
12150        );
12151    }
12152
12153    #[test]
12154    fn trailing_space_in_heading_text_preserved() {
12155        // Issue #400: trailing space in heading text node trimmed on round-trip
12156        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[
12157          {"type":"text","text":"Firefighting Engineers "}
12158        ]}]}"#;
12159        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12160        let md = adf_to_markdown(&doc).unwrap();
12161        let round_tripped = markdown_to_adf(&md).unwrap();
12162        let inlines = round_tripped.content[0].content.as_ref().unwrap();
12163        assert_eq!(
12164            inlines[0].text.as_deref(),
12165            Some("Firefighting Engineers "),
12166            "trailing space in heading should be preserved"
12167        );
12168    }
12169
12170    #[test]
12171    fn trailing_space_in_heading_before_bold_preserved() {
12172        // Issue #400: trailing space before bold sibling in heading
12173        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[
12174          {"type":"text","text":"Classic "},
12175          {"type":"text","text":"bold","marks":[{"type":"strong"}]}
12176        ]}]}"#;
12177        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12178        let md = adf_to_markdown(&doc).unwrap();
12179        let round_tripped = markdown_to_adf(&md).unwrap();
12180        let inlines = round_tripped.content[0].content.as_ref().unwrap();
12181        assert_eq!(
12182            inlines[0].text.as_deref(),
12183            Some("Classic "),
12184            "trailing space in heading text before bold should be preserved"
12185        );
12186    }
12187
12188    #[test]
12189    fn leading_space_in_heading_text_preserved() {
12190        // Issue #492: leading spaces in heading text node stripped on round-trip
12191        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":3},"content":[
12192          {"type":"text","text":"  #general-channel"}
12193        ]}]}"#;
12194        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12195        let md = adf_to_markdown(&doc).unwrap();
12196        let round_tripped = markdown_to_adf(&md).unwrap();
12197        let inlines = round_tripped.content[0].content.as_ref().unwrap();
12198        assert_eq!(
12199            inlines[0].text.as_deref(),
12200            Some("  #general-channel"),
12201            "leading spaces in heading text should be preserved"
12202        );
12203    }
12204
12205    #[test]
12206    fn leading_space_in_heading_before_bold_preserved() {
12207        // Issue #492: leading space before bold sibling in heading
12208        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[
12209          {"type":"text","text":"   indented"},
12210          {"type":"text","text":" bold","marks":[{"type":"strong"}]}
12211        ]}]}"#;
12212        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12213        let md = adf_to_markdown(&doc).unwrap();
12214        let round_tripped = markdown_to_adf(&md).unwrap();
12215        let inlines = round_tripped.content[0].content.as_ref().unwrap();
12216        assert_eq!(
12217            inlines[0].text.as_deref(),
12218            Some("   indented"),
12219            "leading spaces in heading text before bold should be preserved"
12220        );
12221    }
12222
12223    #[test]
12224    fn heading_multiple_leading_spaces_markdown_parse() {
12225        // Issue #492: verify JFM parsing preserves leading spaces
12226        let md = "### \t  #general-channel";
12227        let doc = markdown_to_adf(md).unwrap();
12228        let inlines = doc.content[0].content.as_ref().unwrap();
12229        assert_eq!(
12230            inlines[0].text.as_deref(),
12231            Some("\t  #general-channel"),
12232            "leading whitespace in heading text should be preserved during JFM parsing"
12233        );
12234    }
12235
12236    #[test]
12237    fn trailing_space_in_paragraph_text_preserved() {
12238        // Issue #400: trailing space in paragraph text node preserved on round-trip
12239        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12240          {"type":"text","text":"word followed by space "},
12241          {"type":"text","text":"next node","marks":[{"type":"strong"}]}
12242        ]}]}"#;
12243        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12244        let md = adf_to_markdown(&doc).unwrap();
12245        let round_tripped = markdown_to_adf(&md).unwrap();
12246        let inlines = round_tripped.content[0].content.as_ref().unwrap();
12247        assert_eq!(
12248            inlines[0].text.as_deref(),
12249            Some("word followed by space "),
12250            "trailing space in paragraph text should be preserved"
12251        );
12252    }
12253
12254    #[test]
12255    fn nested_bullet_list_roundtrip() {
12256        // ADF with a listItem containing a paragraph + nested bulletList
12257        let adf_doc = serde_json::json!({
12258            "type": "doc",
12259            "version": 1,
12260            "content": [{
12261                "type": "bulletList",
12262                "content": [{
12263                    "type": "listItem",
12264                    "content": [
12265                        {
12266                            "type": "paragraph",
12267                            "content": [{"type": "text", "text": "parent item"}]
12268                        },
12269                        {
12270                            "type": "bulletList",
12271                            "content": [
12272                                {
12273                                    "type": "listItem",
12274                                    "content": [{
12275                                        "type": "paragraph",
12276                                        "content": [{"type": "text", "text": "sub item 1"}]
12277                                    }]
12278                                },
12279                                {
12280                                    "type": "listItem",
12281                                    "content": [{
12282                                        "type": "paragraph",
12283                                        "content": [{"type": "text", "text": "sub item 2"}]
12284                                    }]
12285                                }
12286                            ]
12287                        }
12288                    ]
12289                }]
12290            }]
12291        });
12292        let doc: AdfDocument = serde_json::from_value(adf_doc).unwrap();
12293        let md = adf_to_markdown(&doc).unwrap();
12294        assert!(
12295            md.contains("- parent item\n"),
12296            "expected top-level item in markdown, got: {md}"
12297        );
12298        assert!(
12299            md.contains("  - sub item 1\n"),
12300            "expected indented sub item 1 in markdown, got: {md}"
12301        );
12302        assert!(
12303            md.contains("  - sub item 2\n"),
12304            "expected indented sub item 2 in markdown, got: {md}"
12305        );
12306
12307        // Round-trip back
12308        let doc2 = markdown_to_adf(&md).unwrap();
12309        let list = &doc2.content[0];
12310        assert_eq!(list.node_type, "bulletList");
12311        let item = &list.content.as_ref().unwrap()[0];
12312        assert_eq!(item.node_type, "listItem");
12313        let item_content = item.content.as_ref().unwrap();
12314        assert_eq!(
12315            item_content.len(),
12316            2,
12317            "listItem should have paragraph + nested list"
12318        );
12319        assert_eq!(item_content[0].node_type, "paragraph");
12320        assert_eq!(item_content[1].node_type, "bulletList");
12321        let sub_items = item_content[1].content.as_ref().unwrap();
12322        assert_eq!(sub_items.len(), 2);
12323    }
12324
12325    #[test]
12326    fn nested_bullet_in_table_cell_roundtrip() {
12327        let md = "::::table\n:::tr\n:::td\n- parent\n  - child\n:::\n:::\n::::\n";
12328        let doc = markdown_to_adf(md).unwrap();
12329        let table = &doc.content[0];
12330        let row = &table.content.as_ref().unwrap()[0];
12331        let cell = &row.content.as_ref().unwrap()[0];
12332        let list = &cell.content.as_ref().unwrap()[0];
12333        assert_eq!(list.node_type, "bulletList");
12334        let item = &list.content.as_ref().unwrap()[0];
12335        let item_content = item.content.as_ref().unwrap();
12336        assert_eq!(
12337            item_content.len(),
12338            2,
12339            "listItem should have paragraph + nested list"
12340        );
12341        assert_eq!(item_content[1].node_type, "bulletList");
12342
12343        // Round-trip: adf→md→adf should preserve the nested list
12344        let md2 = adf_to_markdown(&doc).unwrap();
12345        assert!(
12346            md2.contains("  - child"),
12347            "expected indented child in round-tripped markdown, got: {md2}"
12348        );
12349    }
12350
12351    #[test]
12352    fn nested_ordered_list_roundtrip() {
12353        // Issue #389: nested orderedList inside listItem flattened
12354        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
12355          {"type":"listItem","content":[
12356            {"type":"paragraph","content":[{"type":"text","text":"Top level"}]},
12357            {"type":"orderedList","attrs":{"order":1},"content":[
12358              {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Nested 1"}]}]},
12359              {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Nested 2"}]}]}
12360            ]}
12361          ]},
12362          {"type":"listItem","content":[
12363            {"type":"paragraph","content":[{"type":"text","text":"Second top"}]}
12364          ]}
12365        ]}]}"#;
12366        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12367        let md = adf_to_markdown(&doc).unwrap();
12368        let round_tripped = markdown_to_adf(&md).unwrap();
12369
12370        // Outer list should have 2 items
12371        let outer = &round_tripped.content[0];
12372        assert_eq!(outer.node_type, "orderedList");
12373        assert_eq!(outer.attrs.as_ref().unwrap()["order"], 1);
12374        let outer_items = outer.content.as_ref().unwrap();
12375        assert_eq!(
12376            outer_items.len(),
12377            2,
12378            "outer list should have 2 items, got {}",
12379            outer_items.len()
12380        );
12381
12382        // First item should have paragraph + nested orderedList
12383        let first_item = &outer_items[0];
12384        let first_content = first_item.content.as_ref().unwrap();
12385        assert_eq!(
12386            first_content.len(),
12387            2,
12388            "first listItem should have paragraph + nested list, got {}",
12389            first_content.len()
12390        );
12391        assert_eq!(first_content[0].node_type, "paragraph");
12392        assert_eq!(first_content[1].node_type, "orderedList");
12393        let nested_items = first_content[1].content.as_ref().unwrap();
12394        assert_eq!(nested_items.len(), 2, "nested list should have 2 items");
12395    }
12396
12397    #[test]
12398    fn nested_ordered_list_markdown_parsing() {
12399        // Direct markdown parsing of nested ordered list
12400        let md = "1. Top level\n  1. Nested 1\n  2. Nested 2\n2. Second top\n";
12401        let doc = markdown_to_adf(md).unwrap();
12402        let outer = &doc.content[0];
12403        assert_eq!(outer.node_type, "orderedList");
12404        let outer_items = outer.content.as_ref().unwrap();
12405        assert_eq!(outer_items.len(), 2, "should have 2 top-level items");
12406
12407        let first_content = outer_items[0].content.as_ref().unwrap();
12408        assert_eq!(
12409            first_content.len(),
12410            2,
12411            "first item should have paragraph + nested list"
12412        );
12413        assert_eq!(first_content[1].node_type, "orderedList");
12414    }
12415
12416    #[test]
12417    fn bullet_list_nested_inside_ordered_list() {
12418        // Mixed nesting: bullet list nested inside ordered list
12419        let md = "1. Ordered item\n  - Bullet child 1\n  - Bullet child 2\n2. Second ordered\n";
12420        let doc = markdown_to_adf(md).unwrap();
12421        let outer = &doc.content[0];
12422        assert_eq!(outer.node_type, "orderedList");
12423        let outer_items = outer.content.as_ref().unwrap();
12424        assert_eq!(outer_items.len(), 2);
12425
12426        let first_content = outer_items[0].content.as_ref().unwrap();
12427        assert_eq!(
12428            first_content.len(),
12429            2,
12430            "first item should have paragraph + nested list"
12431        );
12432        assert_eq!(first_content[1].node_type, "bulletList");
12433        let sub_items = first_content[1].content.as_ref().unwrap();
12434        assert_eq!(sub_items.len(), 2, "nested bullet list should have 2 items");
12435    }
12436
12437    #[test]
12438    fn ordered_list_order_attr_always_preserved() {
12439        // order=1 should be preserved, not elided
12440        let md = "1. A\n2. B\n";
12441        let doc = markdown_to_adf(md).unwrap();
12442        let attrs = doc.content[0].attrs.as_ref().unwrap();
12443        assert_eq!(attrs["order"], 1, "order=1 should be explicitly present");
12444
12445        // Round-trip should preserve it
12446        let md2 = adf_to_markdown(&doc).unwrap();
12447        let doc2 = markdown_to_adf(&md2).unwrap();
12448        let attrs2 = doc2.content[0].attrs.as_ref().unwrap();
12449        assert_eq!(attrs2["order"], 1);
12450    }
12451
12452    // ── File media round-trip tests ─────────────────────────────────────
12453
12454    #[test]
12455    fn file_media_roundtrip() {
12456        // ADF with a Confluence file attachment (type:file media)
12457        let adf_doc = serde_json::json!({
12458            "type": "doc",
12459            "version": 1,
12460            "content": [{
12461                "type": "mediaSingle",
12462                "attrs": {"layout": "center"},
12463                "content": [{
12464                    "type": "media",
12465                    "attrs": {
12466                        "type": "file",
12467                        "id": "6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d",
12468                        "collection": "contentId-8220672100",
12469                        "height": 56,
12470                        "width": 312,
12471                        "alt": "Screenshot.png"
12472                    }
12473                }]
12474            }]
12475        });
12476        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
12477        let md = adf_to_markdown(&doc).unwrap();
12478        assert!(
12479            md.contains("type=file"),
12480            "expected type=file in markdown, got: {md}"
12481        );
12482        assert!(
12483            md.contains("id=6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d"),
12484            "expected id in markdown, got: {md}"
12485        );
12486        assert!(
12487            md.contains("collection=contentId-8220672100"),
12488            "expected collection in markdown, got: {md}"
12489        );
12490        // Round-trip back to ADF
12491        let doc2 = markdown_to_adf(&md).unwrap();
12492        let ms = &doc2.content[0];
12493        assert_eq!(ms.node_type, "mediaSingle");
12494        let media = &ms.content.as_ref().unwrap()[0];
12495        assert_eq!(media.node_type, "media");
12496        let attrs = media.attrs.as_ref().unwrap();
12497        assert_eq!(attrs["type"], "file");
12498        assert_eq!(attrs["id"], "6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d");
12499        assert_eq!(attrs["collection"], "contentId-8220672100");
12500        assert_eq!(attrs["height"], 56);
12501        assert_eq!(attrs["width"], 312);
12502        assert_eq!(attrs["alt"], "Screenshot.png");
12503    }
12504
12505    // ── mediaSingle caption tests (issue #470) ──────────────────────────
12506
12507    #[test]
12508    fn media_single_caption_adf_to_markdown() {
12509        let adf_doc = serde_json::json!({
12510            "type": "doc",
12511            "version": 1,
12512            "content": [{
12513                "type": "mediaSingle",
12514                "attrs": {"layout": "center", "width": 400, "widthType": "pixel"},
12515                "content": [
12516                    {
12517                        "type": "media",
12518                        "attrs": {
12519                            "id": "aabbccdd-1234-5678-abcd-aabbccdd1234",
12520                            "type": "file",
12521                            "collection": "contentId-123456",
12522                            "width": 800,
12523                            "height": 600
12524                        }
12525                    },
12526                    {
12527                        "type": "caption",
12528                        "content": [{"type": "text", "text": "An image caption here"}]
12529                    }
12530                ]
12531            }]
12532        });
12533        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
12534        let md = adf_to_markdown(&doc).unwrap();
12535        assert!(
12536            md.contains(":::caption"),
12537            "expected :::caption in markdown, got: {md}"
12538        );
12539        assert!(
12540            md.contains("An image caption here"),
12541            "expected caption text in markdown, got: {md}"
12542        );
12543    }
12544
12545    #[test]
12546    fn media_single_caption_markdown_to_adf() {
12547        let md = "![Screenshot](){type=file id=abc-123 collection=contentId-456 height=600 width=800}\n:::caption\nAn image caption here\n:::\n";
12548        let doc = markdown_to_adf(md).unwrap();
12549        let ms = &doc.content[0];
12550        assert_eq!(ms.node_type, "mediaSingle");
12551        let content = ms.content.as_ref().unwrap();
12552        assert_eq!(content.len(), 2, "expected media + caption children");
12553        assert_eq!(content[0].node_type, "media");
12554        assert_eq!(content[1].node_type, "caption");
12555        let caption_content = content[1].content.as_ref().unwrap();
12556        assert_eq!(
12557            caption_content[0].text.as_deref(),
12558            Some("An image caption here")
12559        );
12560    }
12561
12562    #[test]
12563    fn media_single_caption_round_trip() {
12564        // Full round-trip: ADF → JFM → ADF preserves caption
12565        let adf_doc = serde_json::json!({
12566            "type": "doc",
12567            "version": 1,
12568            "content": [{
12569                "type": "mediaSingle",
12570                "attrs": {"layout": "center", "width": 400, "widthType": "pixel"},
12571                "content": [
12572                    {
12573                        "type": "media",
12574                        "attrs": {
12575                            "id": "aabbccdd-1234-5678-abcd-aabbccdd1234",
12576                            "type": "file",
12577                            "collection": "contentId-123456",
12578                            "width": 800,
12579                            "height": 600
12580                        }
12581                    },
12582                    {
12583                        "type": "caption",
12584                        "content": [{"type": "text", "text": "An image caption here"}]
12585                    }
12586                ]
12587            }]
12588        });
12589        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
12590        let md = adf_to_markdown(&doc).unwrap();
12591        let doc2 = markdown_to_adf(&md).unwrap();
12592        let ms = &doc2.content[0];
12593        assert_eq!(ms.node_type, "mediaSingle");
12594        let content = ms.content.as_ref().unwrap();
12595        assert_eq!(
12596            content.len(),
12597            2,
12598            "expected media + caption after round-trip"
12599        );
12600        assert_eq!(content[1].node_type, "caption");
12601        let caption_content = content[1].content.as_ref().unwrap();
12602        assert_eq!(
12603            caption_content[0].text.as_deref(),
12604            Some("An image caption here")
12605        );
12606    }
12607
12608    #[test]
12609    fn media_single_caption_with_inline_marks() {
12610        let adf_doc = serde_json::json!({
12611            "type": "doc",
12612            "version": 1,
12613            "content": [{
12614                "type": "mediaSingle",
12615                "attrs": {"layout": "center"},
12616                "content": [
12617                    {
12618                        "type": "media",
12619                        "attrs": {"type": "external", "url": "https://example.com/img.png"}
12620                    },
12621                    {
12622                        "type": "caption",
12623                        "content": [
12624                            {"type": "text", "text": "A "},
12625                            {"type": "text", "text": "bold", "marks": [{"type": "strong"}]},
12626                            {"type": "text", "text": " caption"}
12627                        ]
12628                    }
12629                ]
12630            }]
12631        });
12632        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
12633        let md = adf_to_markdown(&doc).unwrap();
12634        assert!(
12635            md.contains("**bold**"),
12636            "expected bold in caption, got: {md}"
12637        );
12638
12639        let doc2 = markdown_to_adf(&md).unwrap();
12640        let content = doc2.content[0].content.as_ref().unwrap();
12641        assert_eq!(content.len(), 2, "expected media + caption");
12642        assert_eq!(content[1].node_type, "caption");
12643        let caption_inlines = content[1].content.as_ref().unwrap();
12644        let bold_node = caption_inlines
12645            .iter()
12646            .find(|n| n.text.as_deref() == Some("bold"))
12647            .unwrap();
12648        let marks = bold_node.marks.as_ref().unwrap();
12649        assert_eq!(marks[0].mark_type, "strong");
12650    }
12651
12652    #[test]
12653    fn media_single_no_caption_unaffected() {
12654        // Existing mediaSingle without caption should be unaffected
12655        let adf_doc = serde_json::json!({
12656            "type": "doc",
12657            "version": 1,
12658            "content": [{
12659                "type": "mediaSingle",
12660                "attrs": {"layout": "center"},
12661                "content": [{
12662                    "type": "media",
12663                    "attrs": {"type": "external", "url": "https://example.com/img.png"}
12664                }]
12665            }]
12666        });
12667        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
12668        let md = adf_to_markdown(&doc).unwrap();
12669        assert!(
12670            !md.contains(":::caption"),
12671            "should not emit caption when none present"
12672        );
12673        let doc2 = markdown_to_adf(&md).unwrap();
12674        let content = doc2.content[0].content.as_ref().unwrap();
12675        assert_eq!(content.len(), 1, "should only have media child");
12676        assert_eq!(content[0].node_type, "media");
12677    }
12678
12679    #[test]
12680    fn media_single_empty_caption_round_trip() {
12681        // Caption node with no content should still round-trip
12682        let adf_doc = serde_json::json!({
12683            "type": "doc",
12684            "version": 1,
12685            "content": [{
12686                "type": "mediaSingle",
12687                "attrs": {"layout": "center"},
12688                "content": [
12689                    {
12690                        "type": "media",
12691                        "attrs": {"type": "external", "url": "https://example.com/img.png"}
12692                    },
12693                    {
12694                        "type": "caption"
12695                    }
12696                ]
12697            }]
12698        });
12699        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
12700        let md = adf_to_markdown(&doc).unwrap();
12701        assert!(
12702            md.contains(":::caption"),
12703            "expected :::caption even for empty caption, got: {md}"
12704        );
12705        assert!(
12706            md.contains(":::\n"),
12707            "expected closing ::: fence, got: {md}"
12708        );
12709    }
12710
12711    #[test]
12712    fn media_single_external_caption_round_trip() {
12713        // External image with caption round-trips
12714        let md = "![alt](https://example.com/img.png)\n:::caption\nImage description\n:::\n";
12715        let doc = markdown_to_adf(md).unwrap();
12716        let ms = &doc.content[0];
12717        assert_eq!(ms.node_type, "mediaSingle");
12718        let content = ms.content.as_ref().unwrap();
12719        assert_eq!(content.len(), 2);
12720        assert_eq!(content[0].node_type, "media");
12721        assert_eq!(content[1].node_type, "caption");
12722
12723        let md2 = adf_to_markdown(&doc).unwrap();
12724        let doc2 = markdown_to_adf(&md2).unwrap();
12725        let content2 = doc2.content[0].content.as_ref().unwrap();
12726        assert_eq!(content2.len(), 2);
12727        assert_eq!(content2[1].node_type, "caption");
12728        let caption_text = content2[1].content.as_ref().unwrap();
12729        assert_eq!(caption_text[0].text.as_deref(), Some("Image description"));
12730    }
12731
12732    // ── mediaSingle caption localId tests (issue #524) ─────────────────
12733
12734    #[test]
12735    fn media_single_caption_localid_roundtrip() {
12736        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"mediaSingle","attrs":{"layout":"center"},"content":[{"type":"media","attrs":{"id":"aabbccdd-1234-5678-abcd-000000000001","type":"file","collection":"test-collection"}},{"type":"caption","attrs":{"localId":"9da8c2104471"},"content":[{"type":"text","text":"a caption with hex localId"}]}]}]}"#;
12737        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12738        let md = adf_to_markdown(&doc).unwrap();
12739        assert!(
12740            md.contains("localId=9da8c2104471"),
12741            "caption localId should appear in markdown: {md}"
12742        );
12743        let rt = markdown_to_adf(&md).unwrap();
12744        let content = rt.content[0].content.as_ref().unwrap();
12745        let caption = &content[1];
12746        assert_eq!(caption.node_type, "caption");
12747        assert_eq!(
12748            caption.attrs.as_ref().unwrap()["localId"],
12749            "9da8c2104471",
12750            "caption localId should round-trip"
12751        );
12752    }
12753
12754    #[test]
12755    fn media_single_caption_without_localid() {
12756        let md = "![Screenshot](){type=file id=abc-123 collection=contentId-456 height=600 width=800}\n:::caption\nPlain caption\n:::\n";
12757        let doc = markdown_to_adf(md).unwrap();
12758        let caption = &doc.content[0].content.as_ref().unwrap()[1];
12759        assert_eq!(caption.node_type, "caption");
12760        assert!(
12761            caption.attrs.is_none(),
12762            "caption without localId should not gain attrs"
12763        );
12764        let md2 = adf_to_markdown(&doc).unwrap();
12765        assert!(
12766            !md2.contains("localId"),
12767            "no localId should appear in output: {md2}"
12768        );
12769    }
12770
12771    #[test]
12772    fn media_single_caption_localid_stripped_when_option_set() {
12773        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"mediaSingle","attrs":{"layout":"center"},"content":[{"type":"media","attrs":{"id":"aabbccdd-1234-5678-abcd-000000000001","type":"file","collection":"test-collection"}},{"type":"caption","attrs":{"localId":"9da8c2104471"},"content":[{"type":"text","text":"stripped caption"}]}]}]}"#;
12774        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12775        let opts = RenderOptions {
12776            strip_local_ids: true,
12777            ..Default::default()
12778        };
12779        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12780        assert!(!md.contains("localId"), "localId should be stripped: {md}");
12781    }
12782
12783    #[test]
12784    fn table_width_roundtrip() {
12785        // ADF table with width attribute
12786        let adf_doc = serde_json::json!({
12787            "type": "doc",
12788            "version": 1,
12789            "content": [{
12790                "type": "table",
12791                "attrs": {"layout": "default", "width": 760.0},
12792                "content": [{
12793                    "type": "tableRow",
12794                    "content": [{
12795                        "type": "tableHeader",
12796                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "H"}]}]
12797                    }]
12798                }]
12799            }]
12800        });
12801        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
12802        let md = adf_to_markdown(&doc).unwrap();
12803        assert!(
12804            md.contains("width=760"),
12805            "expected width=760 in markdown, got: {md}"
12806        );
12807        // Round-trip back to ADF
12808        let doc2 = markdown_to_adf(&md).unwrap();
12809        let table = &doc2.content[0];
12810        assert_eq!(table.node_type, "table");
12811        let table_attrs = table.attrs.as_ref().unwrap();
12812        assert_eq!(table_attrs["width"], 760.0);
12813    }
12814
12815    #[test]
12816    fn file_media_width_type_roundtrip() {
12817        // mediaSingle with widthType:pixel should survive round-trip
12818        let adf_doc = serde_json::json!({
12819            "type": "doc",
12820            "version": 1,
12821            "content": [{
12822                "type": "mediaSingle",
12823                "attrs": {"layout": "center", "width": 312, "widthType": "pixel"},
12824                "content": [{
12825                    "type": "media",
12826                    "attrs": {
12827                        "type": "file",
12828                        "id": "abc123",
12829                        "collection": "contentId-999",
12830                        "height": 56,
12831                        "width": 312
12832                    }
12833                }]
12834            }]
12835        });
12836        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
12837        let md = adf_to_markdown(&doc).unwrap();
12838        assert!(
12839            md.contains("widthType=pixel"),
12840            "expected widthType=pixel in markdown, got: {md}"
12841        );
12842        let doc2 = markdown_to_adf(&md).unwrap();
12843        let ms = &doc2.content[0];
12844        let ms_attrs = ms.attrs.as_ref().unwrap();
12845        assert_eq!(ms_attrs["widthType"], "pixel");
12846        assert_eq!(ms_attrs["width"], 312);
12847    }
12848
12849    #[test]
12850    fn file_media_mode_roundtrip() {
12851        // mediaSingle with mode attr should survive round-trip (issue #431)
12852        let adf_doc = serde_json::json!({
12853            "type": "doc",
12854            "version": 1,
12855            "content": [{
12856                "type": "mediaSingle",
12857                "attrs": {"layout": "wide", "mode": "wide", "width": 1200},
12858                "content": [{
12859                    "type": "media",
12860                    "attrs": {
12861                        "type": "file",
12862                        "id": "abc123",
12863                        "collection": "test",
12864                        "width": 1200,
12865                        "height": 600
12866                    }
12867                }]
12868            }]
12869        });
12870        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
12871        let md = adf_to_markdown(&doc).unwrap();
12872        assert!(
12873            md.contains("mode=wide"),
12874            "expected mode=wide in markdown, got: {md}"
12875        );
12876        let doc2 = markdown_to_adf(&md).unwrap();
12877        let ms = &doc2.content[0];
12878        let ms_attrs = ms.attrs.as_ref().unwrap();
12879        assert_eq!(ms_attrs["mode"], "wide");
12880        assert_eq!(ms_attrs["layout"], "wide");
12881        assert_eq!(ms_attrs["width"], 1200);
12882    }
12883
12884    #[test]
12885    fn external_media_mode_roundtrip() {
12886        // External mediaSingle with mode attr should survive round-trip (issue #431)
12887        let adf_doc = serde_json::json!({
12888            "type": "doc",
12889            "version": 1,
12890            "content": [{
12891                "type": "mediaSingle",
12892                "attrs": {"layout": "wide", "mode": "wide"},
12893                "content": [{
12894                    "type": "media",
12895                    "attrs": {
12896                        "type": "external",
12897                        "url": "https://example.com/image.png"
12898                    }
12899                }]
12900            }]
12901        });
12902        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
12903        let md = adf_to_markdown(&doc).unwrap();
12904        assert!(
12905            md.contains("mode=wide"),
12906            "expected mode=wide in markdown, got: {md}"
12907        );
12908        let doc2 = markdown_to_adf(&md).unwrap();
12909        let ms = &doc2.content[0];
12910        let ms_attrs = ms.attrs.as_ref().unwrap();
12911        assert_eq!(ms_attrs["mode"], "wide");
12912        assert_eq!(ms_attrs["layout"], "wide");
12913    }
12914
12915    #[test]
12916    fn media_mode_only_roundtrip() {
12917        // mediaSingle with mode but default layout should still preserve mode (issue #431)
12918        let adf_doc = serde_json::json!({
12919            "type": "doc",
12920            "version": 1,
12921            "content": [{
12922                "type": "mediaSingle",
12923                "attrs": {"layout": "center", "mode": "default"},
12924                "content": [{
12925                    "type": "media",
12926                    "attrs": {
12927                        "type": "external",
12928                        "url": "https://example.com/image.png"
12929                    }
12930                }]
12931            }]
12932        });
12933        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
12934        let md = adf_to_markdown(&doc).unwrap();
12935        assert!(
12936            md.contains("mode=default"),
12937            "expected mode=default in markdown, got: {md}"
12938        );
12939        let doc2 = markdown_to_adf(&md).unwrap();
12940        let ms = &doc2.content[0];
12941        let ms_attrs = ms.attrs.as_ref().unwrap();
12942        assert_eq!(ms_attrs["mode"], "default");
12943    }
12944
12945    #[test]
12946    fn file_media_hex_localid_roundtrip() {
12947        // Issue #432: short hex localId (non-UUID) must survive round-trip
12948        let adf_doc = serde_json::json!({
12949            "type": "doc",
12950            "version": 1,
12951            "content": [{
12952                "type": "mediaSingle",
12953                "attrs": {"layout": "wide", "width": 1200, "widthType": "pixel"},
12954                "content": [{
12955                    "type": "media",
12956                    "attrs": {
12957                        "type": "file",
12958                        "id": "eb7a9c3b-314e-4458-8200-4b22b67b122e",
12959                        "collection": "contentId-123",
12960                        "height": 484,
12961                        "width": 915,
12962                        "alt": "image.png",
12963                        "localId": "0e79f58ac382"
12964                    }
12965                }]
12966            }]
12967        });
12968        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
12969        let md = adf_to_markdown(&doc).unwrap();
12970        assert!(
12971            md.contains("localId=0e79f58ac382"),
12972            "expected localId=0e79f58ac382 in markdown, got: {md}"
12973        );
12974        let doc2 = markdown_to_adf(&md).unwrap();
12975        let ms = &doc2.content[0];
12976        let media = &ms.content.as_ref().unwrap()[0];
12977        let attrs = media.attrs.as_ref().unwrap();
12978        assert_eq!(attrs["localId"], "0e79f58ac382");
12979    }
12980
12981    #[test]
12982    fn file_media_uuid_localid_roundtrip() {
12983        // UUID-format localId must also survive round-trip
12984        let adf_doc = serde_json::json!({
12985            "type": "doc",
12986            "version": 1,
12987            "content": [{
12988                "type": "mediaSingle",
12989                "attrs": {"layout": "center"},
12990                "content": [{
12991                    "type": "media",
12992                    "attrs": {
12993                        "type": "file",
12994                        "id": "abc-123",
12995                        "collection": "contentId-456",
12996                        "height": 100,
12997                        "width": 200,
12998                        "localId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
12999                    }
13000                }]
13001            }]
13002        });
13003        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
13004        let md = adf_to_markdown(&doc).unwrap();
13005        assert!(
13006            md.contains("localId=a1b2c3d4-e5f6-7890-abcd-ef1234567890"),
13007            "expected UUID localId in markdown, got: {md}"
13008        );
13009        let doc2 = markdown_to_adf(&md).unwrap();
13010        let media = &doc2.content[0].content.as_ref().unwrap()[0];
13011        let attrs = media.attrs.as_ref().unwrap();
13012        assert_eq!(attrs["localId"], "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
13013    }
13014
13015    #[test]
13016    fn file_media_null_uuid_localid_stripped() {
13017        // Null UUID localId should be stripped (consistent with other node types)
13018        let adf_doc = serde_json::json!({
13019            "type": "doc",
13020            "version": 1,
13021            "content": [{
13022                "type": "mediaSingle",
13023                "attrs": {"layout": "center"},
13024                "content": [{
13025                    "type": "media",
13026                    "attrs": {
13027                        "type": "file",
13028                        "id": "abc-123",
13029                        "collection": "contentId-456",
13030                        "height": 100,
13031                        "width": 200,
13032                        "localId": "00000000-0000-0000-0000-000000000000"
13033                    }
13034                }]
13035            }]
13036        });
13037        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
13038        let md = adf_to_markdown(&doc).unwrap();
13039        assert!(
13040            !md.contains("localId="),
13041            "null UUID localId should be stripped, got: {md}"
13042        );
13043    }
13044
13045    #[test]
13046    fn file_media_localid_stripped_when_option_set() {
13047        // localId should be stripped when strip_local_ids option is enabled
13048        let adf_doc = serde_json::json!({
13049            "type": "doc",
13050            "version": 1,
13051            "content": [{
13052                "type": "mediaSingle",
13053                "attrs": {"layout": "center"},
13054                "content": [{
13055                    "type": "media",
13056                    "attrs": {
13057                        "type": "file",
13058                        "id": "abc-123",
13059                        "collection": "contentId-456",
13060                        "height": 100,
13061                        "width": 200,
13062                        "localId": "0e79f58ac382"
13063                    }
13064                }]
13065            }]
13066        });
13067        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
13068        let opts = RenderOptions {
13069            strip_local_ids: true,
13070            ..Default::default()
13071        };
13072        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13073        assert!(
13074            !md.contains("localId="),
13075            "localId should be stripped with strip_local_ids, got: {md}"
13076        );
13077    }
13078
13079    #[test]
13080    fn external_media_localid_roundtrip() {
13081        // localId on external media nodes must also survive round-trip
13082        let adf_doc = serde_json::json!({
13083            "type": "doc",
13084            "version": 1,
13085            "content": [{
13086                "type": "mediaSingle",
13087                "attrs": {"layout": "center"},
13088                "content": [{
13089                    "type": "media",
13090                    "attrs": {
13091                        "type": "external",
13092                        "url": "https://example.com/image.png",
13093                        "alt": "test",
13094                        "localId": "deadbeef1234"
13095                    }
13096                }]
13097            }]
13098        });
13099        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
13100        let md = adf_to_markdown(&doc).unwrap();
13101        assert!(
13102            md.contains("localId=deadbeef1234"),
13103            "expected localId in markdown for external media, got: {md}"
13104        );
13105        let doc2 = markdown_to_adf(&md).unwrap();
13106        let media = &doc2.content[0].content.as_ref().unwrap()[0];
13107        let attrs = media.attrs.as_ref().unwrap();
13108        assert_eq!(attrs["localId"], "deadbeef1234");
13109    }
13110
13111    #[test]
13112    fn bracket_in_text_not_parsed_as_link() {
13113        // "[Task] some text (Link)" — the [Task] must NOT be treated as a link anchor
13114        let md = ":check_mark: [Task] Unable to start trial ([Link](https://example.com/link))";
13115        let doc = markdown_to_adf(md).unwrap();
13116        let para = &doc.content[0];
13117        assert_eq!(para.node_type, "paragraph");
13118        let content = para.content.as_ref().unwrap();
13119        // Find the text node containing "[Task]"
13120        let text_nodes: Vec<_> = content.iter().filter(|n| n.node_type == "text").collect();
13121        let has_task_bracket = text_nodes
13122            .iter()
13123            .any(|n| n.text.as_deref().unwrap_or("").contains("[Task]"));
13124        assert!(
13125            has_task_bracket,
13126            "expected [Task] in plain text, nodes: {content:?}"
13127        );
13128        // Also verify the (Link) is a proper link
13129        let link_nodes: Vec<_> = content
13130            .iter()
13131            .filter(|n| {
13132                n.marks
13133                    .as_ref()
13134                    .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "link"))
13135            })
13136            .collect();
13137        assert!(!link_nodes.is_empty(), "expected a link node");
13138        assert_eq!(
13139            link_nodes[0].text.as_deref(),
13140            Some("Link"),
13141            "link text should be 'Link'"
13142        );
13143    }
13144
13145    #[test]
13146    fn empty_paragraph_roundtrip() {
13147        // An empty ADF paragraph node should survive a round-trip through markdown
13148        let mut adf_in = AdfDocument::new();
13149        adf_in.content = vec![
13150            AdfNode::paragraph(vec![AdfNode::text("before")]),
13151            AdfNode::paragraph(vec![]),
13152            AdfNode::paragraph(vec![AdfNode::text("after")]),
13153        ];
13154        let md = adf_to_markdown(&adf_in).unwrap();
13155        let adf_out = markdown_to_adf(&md).unwrap();
13156        assert_eq!(
13157            adf_out.content.len(),
13158            3,
13159            "should have 3 blocks, markdown:\n{md}"
13160        );
13161        assert_eq!(adf_out.content[0].node_type, "paragraph");
13162        assert_eq!(adf_out.content[1].node_type, "paragraph");
13163        assert!(
13164            adf_out.content[1].content.is_none(),
13165            "middle paragraph should be empty"
13166        );
13167        assert_eq!(adf_out.content[2].node_type, "paragraph");
13168    }
13169
13170    #[test]
13171    fn nbsp_paragraph_roundtrip() {
13172        // Issue #411: paragraph with only NBSP should survive round-trip
13173        let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}]}";
13174        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13175        let md = adf_to_markdown(&doc).unwrap();
13176        assert!(
13177            md.contains("::paragraph["),
13178            "NBSP paragraph should use directive form: {md}"
13179        );
13180        let rt = markdown_to_adf(&md).unwrap();
13181        assert_eq!(rt.content.len(), 1, "should have 1 block");
13182        assert_eq!(rt.content[0].node_type, "paragraph");
13183        let text = rt.content[0].content.as_ref().unwrap()[0]
13184            .text
13185            .as_deref()
13186            .unwrap_or("");
13187        assert_eq!(text, "\u{00a0}", "NBSP should survive round-trip");
13188    }
13189
13190    #[test]
13191    fn nbsp_in_nested_expand_roundtrip() {
13192        // Issue #411 real-world case: NBSP paragraph inside nestedExpand
13193        let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"nestedExpand\",\"attrs\":{\"title\":\"Section\"},\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}]}]}";
13194        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13195        let md = adf_to_markdown(&doc).unwrap();
13196        let rt = markdown_to_adf(&md).unwrap();
13197        let ne = &rt.content[0];
13198        assert_eq!(ne.node_type, "nestedExpand");
13199        let inner = ne.content.as_ref().unwrap();
13200        assert_eq!(inner.len(), 1, "should have 1 inner block");
13201        assert_eq!(inner[0].node_type, "paragraph");
13202        let content = inner[0].content.as_ref().unwrap();
13203        assert!(!content.is_empty(), "paragraph should not be empty");
13204        let text = content[0].text.as_deref().unwrap_or("");
13205        assert_eq!(text, "\u{00a0}", "NBSP should survive in nestedExpand");
13206    }
13207
13208    #[test]
13209    fn nbsp_followed_by_content() {
13210        // NBSP paragraph followed by regular content should not interfere
13211        let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"nestedExpand\",\"attrs\":{\"title\":\"S\"},\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}]},{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"after\"}]}]}";
13212        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13213        let md = adf_to_markdown(&doc).unwrap();
13214        let rt = markdown_to_adf(&md).unwrap();
13215        assert!(rt.content.len() >= 2, "should have at least 2 blocks");
13216        // The second block should be a paragraph with "after"
13217        let after_para = rt.content.iter().find(|n| {
13218            n.node_type == "paragraph"
13219                && n.content
13220                    .as_ref()
13221                    .and_then(|c| c.first())
13222                    .and_then(|n| n.text.as_deref())
13223                    .map_or(false, |t| t.contains("after"))
13224        });
13225        assert!(after_para.is_some(), "should have paragraph with 'after'");
13226    }
13227
13228    #[test]
13229    fn nbsp_paragraph_with_marks_survives() {
13230        // NBSP with bold marks renders as `** **` which contains non-whitespace
13231        // chars and thus doesn't need the directive form — it round-trips naturally
13232        let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\",\"marks\":[{\"type\":\"strong\"}]}]}]}";
13233        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13234        let md = adf_to_markdown(&doc).unwrap();
13235        assert!(md.contains("**"), "should have bold markers: {md}");
13236        let rt = markdown_to_adf(&md).unwrap();
13237        let content = rt.content[0].content.as_ref().unwrap();
13238        assert!(!content.is_empty(), "should preserve content");
13239    }
13240
13241    #[test]
13242    fn regular_paragraph_unchanged() {
13243        // Regression guard: normal paragraphs should NOT use directive form
13244        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}"#;
13245        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13246        let md = adf_to_markdown(&doc).unwrap();
13247        assert!(
13248            !md.contains("::paragraph"),
13249            "regular paragraphs should not use directive form: {md}"
13250        );
13251        assert!(md.contains("hello"));
13252    }
13253
13254    #[test]
13255    fn paragraph_directive_with_content_parsed() {
13256        // ::paragraph[content] should parse to a paragraph with inline nodes
13257        let md = "::paragraph[\u{00a0}]\n";
13258        let doc = markdown_to_adf(md).unwrap();
13259        assert_eq!(doc.content.len(), 1);
13260        assert_eq!(doc.content[0].node_type, "paragraph");
13261        let content = doc.content[0].content.as_ref().unwrap();
13262        assert!(!content.is_empty(), "should have inline content");
13263        assert_eq!(content[0].text.as_deref().unwrap(), "\u{00a0}");
13264    }
13265
13266    #[test]
13267    fn nbsp_paragraph_in_list_item_with_nested_list() {
13268        // Issue #448: NBSP paragraph content lost inside listItem with nested bulletList
13269        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"\u00a0"}]},{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"sub item one"}]}]}]}]}]}]}"#;
13270        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13271        let md = adf_to_markdown(&doc).unwrap();
13272        let rt = markdown_to_adf(&md).unwrap();
13273        let list = &rt.content[0];
13274        assert_eq!(list.node_type, "bulletList");
13275        let item = &list.content.as_ref().unwrap()[0];
13276        let item_content = item.content.as_ref().unwrap();
13277        assert_eq!(
13278            item_content.len(),
13279            2,
13280            "listItem should have paragraph + nested list, got: {item_content:?}"
13281        );
13282        let para = &item_content[0];
13283        assert_eq!(para.node_type, "paragraph");
13284        let para_content = para
13285            .content
13286            .as_ref()
13287            .expect("paragraph should have content");
13288        assert!(
13289            !para_content.is_empty(),
13290            "NBSP paragraph content should not be empty"
13291        );
13292        assert_eq!(
13293            para_content[0].text.as_deref().unwrap(),
13294            "\u{00a0}",
13295            "NBSP should survive round-trip inside listItem"
13296        );
13297    }
13298
13299    #[test]
13300    fn nbsp_paragraph_in_list_item_with_local_ids() {
13301        // Issue #448: NBSP paragraph with localIds inside listItem with nested list
13302        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","attrs":{"localId":"li-001"},"content":[{"type":"paragraph","attrs":{"localId":"p-001"},"content":[{"type":"text","text":"\u00a0"}]},{"type":"bulletList","content":[{"type":"listItem","attrs":{"localId":"li-002"},"content":[{"type":"paragraph","attrs":{"localId":"p-002"},"content":[{"type":"text","text":"sub item"}]}]}]}]}]}]}"#;
13303        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13304        let md = adf_to_markdown(&doc).unwrap();
13305        let rt = markdown_to_adf(&md).unwrap();
13306        let list = &rt.content[0];
13307        let item = &list.content.as_ref().unwrap()[0];
13308        // Check listItem localId
13309        assert_eq!(
13310            item.attrs.as_ref().unwrap()["localId"],
13311            "li-001",
13312            "listItem localId should survive"
13313        );
13314        let item_content = item.content.as_ref().unwrap();
13315        assert_eq!(item_content.len(), 2);
13316        // Check paragraph localId and NBSP content
13317        let para = &item_content[0];
13318        assert_eq!(
13319            para.attrs.as_ref().unwrap()["localId"],
13320            "p-001",
13321            "paragraph localId should survive"
13322        );
13323        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
13324        assert_eq!(text, "\u{00a0}", "NBSP should survive with localIds");
13325    }
13326
13327    #[test]
13328    fn nbsp_paragraph_in_list_item_without_nested_list() {
13329        // NBSP paragraph in a simple listItem (no nested list)
13330        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","attrs":{"localId":"li-001"},"content":[{"type":"paragraph","attrs":{"localId":"p-001"},"content":[{"type":"text","text":"\u00a0"}]}]}]}]}"#;
13331        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13332        let md = adf_to_markdown(&doc).unwrap();
13333        let rt = markdown_to_adf(&md).unwrap();
13334        let list = &rt.content[0];
13335        let item = &list.content.as_ref().unwrap()[0];
13336        let para = &item.content.as_ref().unwrap()[0];
13337        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
13338        assert_eq!(text, "\u{00a0}", "NBSP should survive in simple list item");
13339    }
13340
13341    #[test]
13342    fn nbsp_paragraph_in_ordered_list_item_with_nested_list() {
13343        // NBSP paragraph in ordered listItem with nested bulletList
13344        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","attrs":{"localId":"li-001"},"content":[{"type":"paragraph","attrs":{"localId":"p-001"},"content":[{"type":"text","text":"\u00a0"}]},{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"sub item"}]}]}]}]}]}]}"#;
13345        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13346        let md = adf_to_markdown(&doc).unwrap();
13347        let rt = markdown_to_adf(&md).unwrap();
13348        let list = &rt.content[0];
13349        let item = &list.content.as_ref().unwrap()[0];
13350        let item_content = item.content.as_ref().unwrap();
13351        assert_eq!(item_content.len(), 2);
13352        let para = &item_content[0];
13353        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
13354        assert_eq!(text, "\u{00a0}", "NBSP should survive in ordered list item");
13355    }
13356
13357    #[test]
13358    fn list_item_leading_space_preserved() {
13359        // Leading space in list item text must not be stripped
13360        let md = "- hello world\n- - text";
13361        let doc = markdown_to_adf(md).unwrap();
13362        let list = &doc.content[0];
13363        assert_eq!(list.node_type, "bulletList");
13364        let items = list.content.as_ref().unwrap();
13365        // First item: "hello world" (no leading space, unchanged)
13366        let first_para = &items[0].content.as_ref().unwrap()[0];
13367        let first_text = &first_para.content.as_ref().unwrap()[0];
13368        assert_eq!(first_text.text.as_deref(), Some("hello world"));
13369    }
13370
13371    #[test]
13372    fn list_item_leading_space_not_stripped() {
13373        // When the markdown list item content has a leading space (e.g. " :emoji:"),
13374        // that space must reach parse_inline as-is.
13375        let md = "-  leading space text";
13376        let doc = markdown_to_adf(md).unwrap();
13377        let list = &doc.content[0];
13378        let items = list.content.as_ref().unwrap();
13379        let para = &items[0].content.as_ref().unwrap()[0];
13380        let text_node = &para.content.as_ref().unwrap()[0];
13381        // After "- " (2 chars), trim_end keeps the leading space: " leading space text"
13382        assert_eq!(
13383            text_node.text.as_deref(),
13384            Some(" leading space text"),
13385            "leading space should be preserved"
13386        );
13387    }
13388
13389    // ── Nested container directive tests ───────────────────────────
13390
13391    // ── hardBreak in table cell tests ────────────────────────────
13392
13393    #[test]
13394    fn hardbreak_in_cell_uses_directive_table() {
13395        // A table cell with a hardBreak should NOT use pipe syntax
13396        // because the newline would break the row
13397        let adf = AdfDocument {
13398            version: 1,
13399            doc_type: "doc".to_string(),
13400            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
13401                AdfNode::table_cell(vec![AdfNode::paragraph(vec![
13402                    AdfNode::text("before"),
13403                    AdfNode::hard_break(),
13404                    AdfNode::text("after"),
13405                ])]),
13406            ])])],
13407        };
13408        let md = adf_to_markdown(&adf).unwrap();
13409        // Should render as directive table, not pipe table
13410        assert!(
13411            md.contains(":::td") || md.contains("::::table"),
13412            "Table with hardBreak should use directive form, got:\n{md}"
13413        );
13414        assert!(
13415            !md.contains("| before"),
13416            "Should NOT use pipe syntax with hardBreak"
13417        );
13418    }
13419
13420    #[test]
13421    fn hardbreak_in_cell_roundtrips() {
13422        // Verify the directive table form preserves the hardBreak on round-trip
13423        let adf = AdfDocument {
13424            version: 1,
13425            doc_type: "doc".to_string(),
13426            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
13427                AdfNode::table_cell(vec![AdfNode::paragraph(vec![
13428                    AdfNode::text("line one"),
13429                    AdfNode::hard_break(),
13430                    AdfNode::text("line two"),
13431                ])]),
13432            ])])],
13433        };
13434        let md = adf_to_markdown(&adf).unwrap();
13435        let roundtripped = markdown_to_adf(&md).unwrap();
13436
13437        // Should still have one table with one row with one cell
13438        assert_eq!(roundtripped.content.len(), 1);
13439        assert_eq!(roundtripped.content[0].node_type, "table");
13440        let rows = roundtripped.content[0].content.as_ref().unwrap();
13441        assert_eq!(
13442            rows.len(),
13443            1,
13444            "Should have exactly 1 row, got {}",
13445            rows.len()
13446        );
13447    }
13448
13449    #[test]
13450    fn hardbreak_in_paragraph_roundtrips() {
13451        // Issue #373: hardBreak absorbed into preceding text node
13452        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13453          {"type":"text","text":"line one"},
13454          {"type":"hardBreak"},
13455          {"type":"text","text":"line two"}
13456        ]}]}"#;
13457        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13458        let md = adf_to_markdown(&doc).unwrap();
13459        let round_tripped = markdown_to_adf(&md).unwrap();
13460        let inlines = round_tripped.content[0].content.as_ref().unwrap();
13461        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
13462        assert_eq!(
13463            types,
13464            vec!["text", "hardBreak", "text"],
13465            "hardBreak should be preserved, got: {types:?}"
13466        );
13467        assert_eq!(inlines[0].text.as_deref(), Some("line one"));
13468        assert_eq!(inlines[2].text.as_deref(), Some("line two"));
13469    }
13470
13471    #[test]
13472    fn consecutive_hardbreaks_in_paragraph_roundtrip() {
13473        // Issue #410: consecutive hardBreak nodes collapsed on round-trip
13474        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13475          {"type":"text","text":"before"},
13476          {"type":"hardBreak"},
13477          {"type":"hardBreak"},
13478          {"type":"text","text":"after"}
13479        ]}]}"#;
13480        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13481        let md = adf_to_markdown(&doc).unwrap();
13482        let round_tripped = markdown_to_adf(&md).unwrap();
13483        assert_eq!(
13484            round_tripped.content.len(),
13485            1,
13486            "Should remain a single paragraph, got {} blocks",
13487            round_tripped.content.len()
13488        );
13489        let inlines = round_tripped.content[0].content.as_ref().unwrap();
13490        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
13491        assert_eq!(
13492            types,
13493            vec!["text", "hardBreak", "hardBreak", "text"],
13494            "Both hardBreaks should be preserved, got: {types:?}"
13495        );
13496        assert_eq!(inlines[0].text.as_deref(), Some("before"));
13497        assert_eq!(inlines[3].text.as_deref(), Some("after"));
13498    }
13499
13500    #[test]
13501    fn hardbreak_only_paragraph_roundtrips() {
13502        // Issue #410: paragraph whose only content is a hardBreak is dropped
13503        let adf_json = r#"{"version":1,"type":"doc","content":[
13504          {"type":"paragraph","content":[{"type":"hardBreak"}]}
13505        ]}"#;
13506        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13507        let md = adf_to_markdown(&doc).unwrap();
13508        let round_tripped = markdown_to_adf(&md).unwrap();
13509        assert_eq!(
13510            round_tripped.content.len(),
13511            1,
13512            "Paragraph should not be dropped, got {} blocks",
13513            round_tripped.content.len()
13514        );
13515        let inlines = round_tripped.content[0].content.as_ref().unwrap();
13516        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
13517        assert_eq!(
13518            types,
13519            vec!["hardBreak"],
13520            "hardBreak-only paragraph should preserve its content, got: {types:?}"
13521        );
13522    }
13523
13524    #[test]
13525    fn issue_410_full_reproducer_roundtrips() {
13526        // Full reproducer from issue #410: consecutive hardBreaks + hardBreak-only paragraph
13527        let adf_json = r#"{"version":1,"type":"doc","content":[
13528          {"type":"paragraph","content":[
13529            {"type":"text","text":"before"},
13530            {"type":"hardBreak"},
13531            {"type":"hardBreak"},
13532            {"type":"text","text":"after"}
13533          ]},
13534          {"type":"paragraph","content":[
13535            {"type":"hardBreak"}
13536          ]}
13537        ]}"#;
13538        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13539        let md = adf_to_markdown(&doc).unwrap();
13540        let round_tripped = markdown_to_adf(&md).unwrap();
13541        assert_eq!(
13542            round_tripped.content.len(),
13543            2,
13544            "Should have exactly 2 paragraphs, got {}",
13545            round_tripped.content.len()
13546        );
13547        // First paragraph: text, hardBreak, hardBreak, text
13548        let p1 = round_tripped.content[0].content.as_ref().unwrap();
13549        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
13550        assert_eq!(types1, vec!["text", "hardBreak", "hardBreak", "text"]);
13551        // Second paragraph: hardBreak only
13552        let p2 = round_tripped.content[1].content.as_ref().unwrap();
13553        let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
13554        assert_eq!(types2, vec!["hardBreak"]);
13555    }
13556
13557    #[test]
13558    fn trailing_space_hardbreak_still_parsed() {
13559        // Backward compatibility: trailing-space hardBreak (old JFM format) still parses
13560        let md = "line one  \nline two\n";
13561        let doc = markdown_to_adf(md).unwrap();
13562        let inlines = doc.content[0].content.as_ref().unwrap();
13563        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
13564        assert_eq!(
13565            types,
13566            vec!["text", "hardBreak", "text"],
13567            "Trailing-space hardBreak should still parse, got: {types:?}"
13568        );
13569    }
13570
13571    #[test]
13572    fn trailing_hardbreak_at_end_of_paragraph_roundtrips() {
13573        // A paragraph ending with a hardBreak (no text after it)
13574        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13575          {"type":"text","text":"text"},
13576          {"type":"hardBreak"}
13577        ]}]}"#;
13578        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13579        let md = adf_to_markdown(&doc).unwrap();
13580        let round_tripped = markdown_to_adf(&md).unwrap();
13581        let inlines = round_tripped.content[0].content.as_ref().unwrap();
13582        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
13583        assert_eq!(
13584            types,
13585            vec!["text", "hardBreak"],
13586            "Trailing hardBreak should be preserved, got: {types:?}"
13587        );
13588    }
13589
13590    #[test]
13591    #[test]
13592    fn table_with_header_row_uses_pipe_syntax() {
13593        // A table with tableHeader in the first row should use pipe syntax
13594        let adf = AdfDocument {
13595            version: 1,
13596            doc_type: "doc".to_string(),
13597            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
13598                AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("header cell")])]),
13599            ])])],
13600        };
13601        let md = adf_to_markdown(&adf).unwrap();
13602        assert!(
13603            md.contains("| header cell |"),
13604            "Table with header row should use pipe syntax, got:\n{md}"
13605        );
13606    }
13607
13608    #[test]
13609    fn table_without_header_row_uses_directive_syntax() {
13610        // Issue #392: tableCell-only first row must use directive syntax
13611        // to avoid converting tableCell → tableHeader on round-trip
13612        let adf = AdfDocument {
13613            version: 1,
13614            doc_type: "doc".to_string(),
13615            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
13616                AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("simple cell")])]),
13617            ])])],
13618        };
13619        let md = adf_to_markdown(&adf).unwrap();
13620        assert!(
13621            md.contains("::::table"),
13622            "Table without header row should use directive syntax, got:\n{md}"
13623        );
13624    }
13625
13626    #[test]
13627    fn tablecell_first_row_preserved_on_roundtrip() {
13628        // Issue #392: tableCell in first row round-trips as tableHeader
13629        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{},"content":[
13630          {"type":"tableRow","content":[
13631            {"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"row1 cell"}]}]}
13632          ]},
13633          {"type":"tableRow","content":[
13634            {"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"row2 cell"}]}]}
13635          ]}
13636        ]}]}"#;
13637        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13638        let md = adf_to_markdown(&doc).unwrap();
13639        let round_tripped = markdown_to_adf(&md).unwrap();
13640        let rows = round_tripped.content[0].content.as_ref().unwrap();
13641        let row0_cell = &rows[0].content.as_ref().unwrap()[0];
13642        assert_eq!(
13643            row0_cell.node_type, "tableCell",
13644            "first row cell should remain tableCell, got: {}",
13645            row0_cell.node_type
13646        );
13647        let row1_cell = &rows[1].content.as_ref().unwrap()[0];
13648        assert_eq!(row1_cell.node_type, "tableCell");
13649    }
13650
13651    #[test]
13652    fn mixed_header_and_cell_first_row_uses_pipe() {
13653        // A first row with at least one tableHeader qualifies for pipe syntax
13654        let adf = AdfDocument {
13655            version: 1,
13656            doc_type: "doc".to_string(),
13657            content: vec![AdfNode::table(vec![
13658                AdfNode::table_row(vec![
13659                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
13660                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
13661                ]),
13662                AdfNode::table_row(vec![
13663                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C1")])]),
13664                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
13665                ]),
13666            ])],
13667        };
13668        let md = adf_to_markdown(&adf).unwrap();
13669        assert!(
13670            md.contains("| H1 |"),
13671            "Table with header first row should use pipe syntax, got:\n{md}"
13672        );
13673        assert!(!md.contains("::::table"), "should not use directive syntax");
13674    }
13675
13676    #[test]
13677    fn cell_contains_hard_break_true() {
13678        let para = AdfNode::paragraph(vec![
13679            AdfNode::text("a"),
13680            AdfNode::hard_break(),
13681            AdfNode::text("b"),
13682        ]);
13683        assert!(cell_contains_hard_break(&para));
13684    }
13685
13686    #[test]
13687    fn cell_contains_hard_break_false() {
13688        let para = AdfNode::paragraph(vec![AdfNode::text("no break here")]);
13689        assert!(!cell_contains_hard_break(&para));
13690    }
13691
13692    #[test]
13693    fn cell_contains_hard_break_empty() {
13694        let para = AdfNode::paragraph(vec![]);
13695        assert!(!cell_contains_hard_break(&para));
13696    }
13697
13698    // ── Multi-paragraph container tests ──────────────────────────
13699
13700    #[test]
13701    fn multi_paragraph_panel_roundtrips() {
13702        let adf = AdfDocument {
13703            version: 1,
13704            doc_type: "doc".to_string(),
13705            content: vec![AdfNode {
13706                node_type: "panel".to_string(),
13707                attrs: Some(serde_json::json!({"panelType": "info"})),
13708                content: Some(vec![
13709                    AdfNode::paragraph(vec![AdfNode::text("First paragraph.")]),
13710                    AdfNode::paragraph(vec![AdfNode::text("Second paragraph.")]),
13711                ]),
13712                text: None,
13713                marks: None,
13714                local_id: None,
13715                parameters: None,
13716            }],
13717        };
13718
13719        let md = adf_to_markdown(&adf).unwrap();
13720        // Should have blank line between paragraphs inside the panel
13721        assert!(
13722            md.contains("First paragraph.\n\nSecond paragraph."),
13723            "Panel should have blank line between paragraphs, got:\n{md}"
13724        );
13725
13726        // Round-trip should preserve two separate paragraphs
13727        let roundtripped = markdown_to_adf(&md).unwrap();
13728        assert_eq!(roundtripped.content.len(), 1);
13729        assert_eq!(roundtripped.content[0].node_type, "panel");
13730        let panel_content = roundtripped.content[0].content.as_ref().unwrap();
13731        assert_eq!(
13732            panel_content.len(),
13733            2,
13734            "Panel should have 2 paragraphs after round-trip, got {}",
13735            panel_content.len()
13736        );
13737    }
13738
13739    #[test]
13740    fn multi_paragraph_expand_roundtrips() {
13741        let adf = AdfDocument {
13742            version: 1,
13743            doc_type: "doc".to_string(),
13744            content: vec![AdfNode {
13745                node_type: "expand".to_string(),
13746                attrs: Some(serde_json::json!({"title": "Details"})),
13747                content: Some(vec![
13748                    AdfNode::paragraph(vec![AdfNode::text("Para one.")]),
13749                    AdfNode::paragraph(vec![AdfNode::text("Para two.")]),
13750                ]),
13751                text: None,
13752                marks: None,
13753                local_id: None,
13754                parameters: None,
13755            }],
13756        };
13757
13758        let md = adf_to_markdown(&adf).unwrap();
13759        let roundtripped = markdown_to_adf(&md).unwrap();
13760        let expand_content = roundtripped.content[0].content.as_ref().unwrap();
13761        assert_eq!(
13762            expand_content.len(),
13763            2,
13764            "Expand should have 2 paragraphs after round-trip, got {}",
13765            expand_content.len()
13766        );
13767    }
13768
13769    #[test]
13770    fn consecutive_nested_expands_in_table_cell_roundtrip() {
13771        let cell_content = vec![
13772            AdfNode {
13773                node_type: "nestedExpand".to_string(),
13774                attrs: Some(serde_json::json!({"title": "First"})),
13775                content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("item 1")])]),
13776                text: None,
13777                marks: None,
13778                local_id: None,
13779                parameters: None,
13780            },
13781            AdfNode {
13782                node_type: "nestedExpand".to_string(),
13783                attrs: Some(serde_json::json!({"title": "Second"})),
13784                content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("item 2")])]),
13785                text: None,
13786                marks: None,
13787                local_id: None,
13788                parameters: None,
13789            },
13790        ];
13791        let adf = AdfDocument {
13792            version: 1,
13793            doc_type: "doc".to_string(),
13794            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
13795                AdfNode::table_cell(cell_content),
13796            ])])],
13797        };
13798
13799        let md = adf_to_markdown(&adf).unwrap();
13800        assert!(
13801            md.contains(":::\n\n:::nested-expand"),
13802            "Should have blank line between consecutive nested-expands in cell, got:\n{md}"
13803        );
13804
13805        let rt = markdown_to_adf(&md).unwrap();
13806        let cell = &rt.content[0].content.as_ref().unwrap()[0]
13807            .content
13808            .as_ref()
13809            .unwrap()[0];
13810        let cell_nodes = cell.content.as_ref().unwrap();
13811        let expand_count = cell_nodes
13812            .iter()
13813            .filter(|n| n.node_type == "nestedExpand")
13814            .count();
13815        assert_eq!(
13816            expand_count, 2,
13817            "Both nested-expands should survive round-trip, got {expand_count}"
13818        );
13819    }
13820
13821    #[test]
13822    fn multi_paragraph_in_table_cell_roundtrip() {
13823        // Two paragraphs inside a directive table cell should survive round-trip
13824        let adf = AdfDocument {
13825            version: 1,
13826            doc_type: "doc".to_string(),
13827            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
13828                AdfNode::table_cell(vec![
13829                    AdfNode::paragraph(vec![AdfNode::text("Para one.")]),
13830                    AdfNode::paragraph(vec![AdfNode::text("Para two.")]),
13831                ]),
13832            ])])],
13833        };
13834
13835        let md = adf_to_markdown(&adf).unwrap();
13836        assert!(
13837            md.contains("Para one.\n\nPara two."),
13838            "Should have blank line between paragraphs in cell, got:\n{md}"
13839        );
13840
13841        let rt = markdown_to_adf(&md).unwrap();
13842        let cell = &rt.content[0].content.as_ref().unwrap()[0]
13843            .content
13844            .as_ref()
13845            .unwrap()[0];
13846        let para_count = cell
13847            .content
13848            .as_ref()
13849            .unwrap()
13850            .iter()
13851            .filter(|n| n.node_type == "paragraph")
13852            .count();
13853        assert_eq!(para_count, 2, "Both paragraphs should survive round-trip");
13854    }
13855
13856    #[test]
13857    fn panel_inside_table_cell_roundtrip() {
13858        // A panel inside a directive table cell
13859        let adf = AdfDocument {
13860            version: 1,
13861            doc_type: "doc".to_string(),
13862            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
13863                AdfNode::table_cell(vec![
13864                    AdfNode::paragraph(vec![AdfNode::text("Before panel.")]),
13865                    AdfNode {
13866                        node_type: "panel".to_string(),
13867                        attrs: Some(serde_json::json!({"panelType": "info"})),
13868                        content: Some(vec![AdfNode::paragraph(vec![AdfNode::text(
13869                            "Panel content",
13870                        )])]),
13871                        text: None,
13872                        marks: None,
13873                        local_id: None,
13874                        parameters: None,
13875                    },
13876                ]),
13877            ])])],
13878        };
13879
13880        let md = adf_to_markdown(&adf).unwrap();
13881        assert!(
13882            md.contains(":::panel"),
13883            "Should contain panel directive, got:\n{md}"
13884        );
13885
13886        let rt = markdown_to_adf(&md).unwrap();
13887        let cell = &rt.content[0].content.as_ref().unwrap()[0]
13888            .content
13889            .as_ref()
13890            .unwrap()[0];
13891        let has_panel = cell
13892            .content
13893            .as_ref()
13894            .unwrap()
13895            .iter()
13896            .any(|n| n.node_type == "panel");
13897        assert!(has_panel, "Panel should survive round-trip in table cell");
13898    }
13899
13900    #[test]
13901    fn three_consecutive_expands_in_table_cell() {
13902        let make_expand = |title: &str| AdfNode {
13903            node_type: "nestedExpand".to_string(),
13904            attrs: Some(serde_json::json!({"title": title})),
13905            content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("content")])]),
13906            text: None,
13907            marks: None,
13908            local_id: None,
13909            parameters: None,
13910        };
13911        let adf = AdfDocument {
13912            version: 1,
13913            doc_type: "doc".to_string(),
13914            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
13915                AdfNode::table_cell(vec![
13916                    make_expand("First"),
13917                    make_expand("Second"),
13918                    make_expand("Third"),
13919                ]),
13920            ])])],
13921        };
13922
13923        let md = adf_to_markdown(&adf).unwrap();
13924        let rt = markdown_to_adf(&md).unwrap();
13925        let cell = &rt.content[0].content.as_ref().unwrap()[0]
13926            .content
13927            .as_ref()
13928            .unwrap()[0];
13929        let expand_count = cell
13930            .content
13931            .as_ref()
13932            .unwrap()
13933            .iter()
13934            .filter(|n| n.node_type == "nestedExpand")
13935            .count();
13936        assert_eq!(expand_count, 3, "All 3 expands should survive round-trip");
13937    }
13938
13939    // ── Nested container directive tests ───────────────────────────
13940
13941    #[test]
13942    fn nested_expand_inside_panel() {
13943        let md = ":::panel{type=info}\n:::expand{title=\"Details\"}\nHidden content\n:::\nMore panel content\n:::";
13944        let adf = markdown_to_adf(md).unwrap();
13945
13946        // Should produce a panel node
13947        assert_eq!(adf.content.len(), 1);
13948        assert_eq!(adf.content[0].node_type, "panel");
13949
13950        // Panel should contain the expand AND "More panel content"
13951        let panel_content = adf.content[0].content.as_ref().unwrap();
13952        assert!(
13953            panel_content.len() >= 2,
13954            "Panel should contain expand + paragraph, got {} nodes",
13955            panel_content.len()
13956        );
13957    }
13958
13959    #[test]
13960    fn nested_expand_inside_table_cell() {
13961        let md = "::::table\n:::tr\n:::td\n:::expand{title=\"Details\"}\nExpand content\n:::\n:::\n:::\n::::";
13962        let adf = markdown_to_adf(md).unwrap();
13963
13964        // Should produce a table
13965        assert_eq!(adf.content.len(), 1);
13966        assert_eq!(adf.content[0].node_type, "table");
13967
13968        // Table -> row -> cell -> should contain an expand node
13969        let rows = adf.content[0].content.as_ref().unwrap();
13970        assert_eq!(rows.len(), 1);
13971        let cells = rows[0].content.as_ref().unwrap();
13972        assert_eq!(cells.len(), 1);
13973        let cell_content = cells[0].content.as_ref().unwrap();
13974        assert!(
13975            cell_content.iter().any(|n| n.node_type == "expand"),
13976            "Cell should contain an expand node, got: {:?}",
13977            cell_content
13978                .iter()
13979                .map(|n| &n.node_type)
13980                .collect::<Vec<_>>()
13981        );
13982    }
13983
13984    #[test]
13985    fn nested_expand_inside_layout_column() {
13986        let md = ":::layout\n:::column{width=100}\n:::expand{title=\"Col Expand\"}\nExpanded\n:::\n:::\n:::";
13987        let adf = markdown_to_adf(md).unwrap();
13988
13989        assert_eq!(adf.content.len(), 1);
13990        assert_eq!(adf.content[0].node_type, "layoutSection");
13991
13992        let columns = adf.content[0].content.as_ref().unwrap();
13993        assert_eq!(columns.len(), 1);
13994        let col_content = columns[0].content.as_ref().unwrap();
13995        assert!(
13996            col_content.iter().any(|n| n.node_type == "expand"),
13997            "Column should contain an expand node, got: {:?}",
13998            col_content.iter().map(|n| &n.node_type).collect::<Vec<_>>()
13999        );
14000    }
14001
14002    #[test]
14003    fn expand_localid_in_directive_attrs() {
14004        // Issue #412: localId should be in directive attrs, not trailing text
14005        let adf_json = r#"{"version":1,"type":"doc","content":[
14006          {"type":"expand","attrs":{"localId":"exp-001","title":"Details"},"content":[
14007            {"type":"paragraph","content":[{"type":"text","text":"body"}]}
14008          ]}
14009        ]}"#;
14010        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14011        let md = adf_to_markdown(&doc).unwrap();
14012        assert!(
14013            md.contains("localId=exp-001"),
14014            "should contain localId: {md}"
14015        );
14016        assert!(
14017            md.contains(":::expand{"),
14018            "should have expand directive with attrs: {md}"
14019        );
14020        assert!(
14021            !md.contains(":::\n{localId="),
14022            "localId should NOT be trailing: {md}"
14023        );
14024    }
14025
14026    #[test]
14027    fn expand_localid_roundtrip() {
14028        let adf_json = r#"{"version":1,"type":"doc","content":[
14029          {"type":"expand","attrs":{"localId":"exp-001","title":"Details"},"content":[
14030            {"type":"paragraph","content":[{"type":"text","text":"body"}]}
14031          ]}
14032        ]}"#;
14033        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14034        let md = adf_to_markdown(&doc).unwrap();
14035        let rt = markdown_to_adf(&md).unwrap();
14036        let expand = &rt.content[0];
14037        assert_eq!(expand.node_type, "expand");
14038        assert_eq!(
14039            expand.local_id.as_deref(),
14040            Some("exp-001"),
14041            "expand localId should survive round-trip"
14042        );
14043        assert_eq!(
14044            expand.attrs.as_ref().unwrap()["title"],
14045            "Details",
14046            "expand title should survive round-trip"
14047        );
14048    }
14049
14050    #[test]
14051    fn nested_expand_localid_roundtrip() {
14052        let adf_json = r#"{"version":1,"type":"doc","content":[
14053          {"type":"nestedExpand","attrs":{"localId":"ne-001","title":"S"},"content":[
14054            {"type":"paragraph","content":[{"type":"text","text":"content"}]}
14055          ]}
14056        ]}"#;
14057        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14058        let md = adf_to_markdown(&doc).unwrap();
14059        assert!(
14060            md.contains(":::nested-expand{"),
14061            "should have directive: {md}"
14062        );
14063        assert!(md.contains("localId=ne-001"), "should have localId: {md}");
14064        let rt = markdown_to_adf(&md).unwrap();
14065        let ne = &rt.content[0];
14066        assert_eq!(ne.node_type, "nestedExpand");
14067        assert_eq!(ne.local_id.as_deref(), Some("ne-001"));
14068    }
14069
14070    #[test]
14071    fn nested_expand_localid_followed_by_content() {
14072        // Issue #412 reproducer: localId must not leak into following paragraph
14073        let adf_json = "{\
14074            \"version\":1,\"type\":\"doc\",\"content\":[\
14075              {\"type\":\"nestedExpand\",\"attrs\":{\"localId\":\"exp-001\",\"title\":\"S\"},\"content\":[\
14076                {\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}\
14077              ]},\
14078              {\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"after\"}]}\
14079            ]}";
14080        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14081        let md = adf_to_markdown(&doc).unwrap();
14082        let rt = markdown_to_adf(&md).unwrap();
14083        // nestedExpand should have localId
14084        let ne = &rt.content[0];
14085        assert_eq!(ne.node_type, "nestedExpand");
14086        assert_eq!(
14087            ne.local_id.as_deref(),
14088            Some("exp-001"),
14089            "nestedExpand should preserve localId"
14090        );
14091        // Following paragraph should contain "after", not "{localId=...}"
14092        let para = &rt.content[1];
14093        assert_eq!(para.node_type, "paragraph");
14094        let text = para.content.as_ref().unwrap()[0]
14095            .text
14096            .as_deref()
14097            .unwrap_or("");
14098        assert!(
14099            !text.contains("localId"),
14100            "following paragraph should not contain localId: {text}"
14101        );
14102        assert!(
14103            text.contains("after"),
14104            "following paragraph should contain 'after': {text}"
14105        );
14106    }
14107
14108    #[test]
14109    fn expand_localid_without_title() {
14110        let adf_json = r#"{"version":1,"type":"doc","content":[
14111          {"type":"expand","attrs":{"localId":"exp-002"},"content":[
14112            {"type":"paragraph","content":[{"type":"text","text":"no title"}]}
14113          ]}
14114        ]}"#;
14115        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14116        let md = adf_to_markdown(&doc).unwrap();
14117        assert!(
14118            md.contains(":::expand{localId=exp-002}"),
14119            "should have localId without title: {md}"
14120        );
14121        let rt = markdown_to_adf(&md).unwrap();
14122        assert_eq!(rt.content[0].local_id.as_deref(), Some("exp-002"));
14123    }
14124
14125    #[test]
14126    fn expand_localid_stripped() {
14127        let adf_json = r#"{"version":1,"type":"doc","content":[
14128          {"type":"expand","attrs":{"localId":"exp-001","title":"X"},"content":[
14129            {"type":"paragraph","content":[{"type":"text","text":"body"}]}
14130          ]}
14131        ]}"#;
14132        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14133        let opts = RenderOptions {
14134            strip_local_ids: true,
14135        };
14136        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
14137        assert!(!md.contains("localId"), "localId should be stripped: {md}");
14138        assert!(
14139            md.contains(":::expand{title=\"X\"}"),
14140            "title should remain: {md}"
14141        );
14142    }
14143
14144    // ── Issue #444: top-level localId and parameters on expand ──
14145
14146    #[test]
14147    fn expand_top_level_localid_roundtrip() {
14148        // localId as a top-level field (not inside attrs) should survive round-trip
14149        let adf_json = r#"{"version":1,"type":"doc","content":[
14150          {"type":"expand","attrs":{"title":"My Section"},"localId":"abc-123","content":[
14151            {"type":"paragraph","content":[{"type":"text","text":"hello"}]}
14152          ]}
14153        ]}"#;
14154        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14155        assert_eq!(doc.content[0].local_id.as_deref(), Some("abc-123"));
14156        let md = adf_to_markdown(&doc).unwrap();
14157        assert!(
14158            md.contains("localId=abc-123"),
14159            "JFM should contain localId: {md}"
14160        );
14161        let rt = markdown_to_adf(&md).unwrap();
14162        let expand = &rt.content[0];
14163        assert_eq!(expand.node_type, "expand");
14164        assert_eq!(expand.local_id.as_deref(), Some("abc-123"));
14165        assert_eq!(
14166            expand.attrs.as_ref().unwrap()["title"],
14167            "My Section",
14168            "title should survive round-trip"
14169        );
14170    }
14171
14172    #[test]
14173    fn expand_parameters_roundtrip() {
14174        // parameters (macroMetadata) should survive round-trip
14175        let adf_json = r#"{"version":1,"type":"doc","content":[
14176          {"type":"expand","attrs":{"title":"Props"},"parameters":{"macroMetadata":{"macroId":{"value":"m-001"},"schemaVersion":{"value":"1"}}},"content":[
14177            {"type":"paragraph","content":[{"type":"text","text":"body"}]}
14178          ]}
14179        ]}"#;
14180        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14181        assert!(doc.content[0].parameters.is_some());
14182        let md = adf_to_markdown(&doc).unwrap();
14183        assert!(md.contains("params="), "JFM should contain params: {md}");
14184        let rt = markdown_to_adf(&md).unwrap();
14185        let expand = &rt.content[0];
14186        let params = expand
14187            .parameters
14188            .as_ref()
14189            .expect("parameters should survive round-trip");
14190        assert_eq!(params["macroMetadata"]["macroId"]["value"], "m-001");
14191        assert_eq!(params["macroMetadata"]["schemaVersion"]["value"], "1");
14192    }
14193
14194    #[test]
14195    fn expand_localid_and_parameters_roundtrip() {
14196        // Issue #444: both localId and parameters on expand should survive round-trip
14197        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"expand","attrs":{"title":"My Section"},"localId":"abc-123","parameters":{"macroMetadata":{"macroId":{"value":"macro-001"},"schemaVersion":{"value":"1"},"title":"Page Properties"}},"content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}]}"#;
14198        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14199        let md = adf_to_markdown(&doc).unwrap();
14200        let rt = markdown_to_adf(&md).unwrap();
14201        let expand = &rt.content[0];
14202        assert_eq!(expand.node_type, "expand");
14203        assert_eq!(expand.local_id.as_deref(), Some("abc-123"));
14204        assert_eq!(expand.attrs.as_ref().unwrap()["title"], "My Section");
14205        let params = expand
14206            .parameters
14207            .as_ref()
14208            .expect("parameters should survive");
14209        assert_eq!(params["macroMetadata"]["macroId"]["value"], "macro-001");
14210        assert_eq!(params["macroMetadata"]["title"], "Page Properties");
14211    }
14212
14213    #[test]
14214    fn nested_expand_top_level_localid_and_parameters_roundtrip() {
14215        let adf_json = r#"{"version":1,"type":"doc","content":[
14216          {"type":"nestedExpand","attrs":{"title":"Nested"},"localId":"ne-100","parameters":{"macroMetadata":{"macroId":{"value":"nm-001"}}},"content":[
14217            {"type":"paragraph","content":[{"type":"text","text":"inner"}]}
14218          ]}
14219        ]}"#;
14220        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14221        let md = adf_to_markdown(&doc).unwrap();
14222        assert!(
14223            md.contains(":::nested-expand{"),
14224            "should use nested-expand: {md}"
14225        );
14226        assert!(md.contains("localId=ne-100"), "should have localId: {md}");
14227        assert!(md.contains("params="), "should have params: {md}");
14228        let rt = markdown_to_adf(&md).unwrap();
14229        let ne = &rt.content[0];
14230        assert_eq!(ne.node_type, "nestedExpand");
14231        assert_eq!(ne.local_id.as_deref(), Some("ne-100"));
14232        assert_eq!(
14233            ne.parameters.as_ref().unwrap()["macroMetadata"]["macroId"]["value"],
14234            "nm-001"
14235        );
14236    }
14237
14238    #[test]
14239    fn expand_top_level_localid_stripped() {
14240        // strip_local_ids should strip top-level localId too
14241        let adf_json = r#"{"version":1,"type":"doc","content":[
14242          {"type":"expand","attrs":{"title":"X"},"localId":"exp-strip","content":[
14243            {"type":"paragraph","content":[{"type":"text","text":"body"}]}
14244          ]}
14245        ]}"#;
14246        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14247        let opts = RenderOptions {
14248            strip_local_ids: true,
14249        };
14250        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
14251        assert!(!md.contains("localId"), "localId should be stripped: {md}");
14252        assert!(
14253            md.contains(":::expand{title=\"X\"}"),
14254            "title should remain: {md}"
14255        );
14256    }
14257
14258    #[test]
14259    fn expand_parameters_without_localid() {
14260        // parameters without localId should work
14261        let adf_json = r#"{"version":1,"type":"doc","content":[
14262          {"type":"expand","attrs":{"title":"P"},"parameters":{"macroMetadata":{"macroId":{"value":"solo"}}},"content":[
14263            {"type":"paragraph","content":[{"type":"text","text":"data"}]}
14264          ]}
14265        ]}"#;
14266        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14267        let md = adf_to_markdown(&doc).unwrap();
14268        assert!(!md.contains("localId"), "no localId: {md}");
14269        assert!(md.contains("params="), "has params: {md}");
14270        let rt = markdown_to_adf(&md).unwrap();
14271        assert!(rt.content[0].local_id.is_none());
14272        assert_eq!(
14273            rt.content[0].parameters.as_ref().unwrap()["macroMetadata"]["macroId"]["value"],
14274            "solo"
14275        );
14276    }
14277
14278    #[test]
14279    fn expand_localid_without_parameters() {
14280        // top-level localId without parameters should work
14281        let adf_json = r#"{"version":1,"type":"doc","content":[
14282          {"type":"expand","attrs":{"title":"L"},"localId":"lid-only","content":[
14283            {"type":"paragraph","content":[{"type":"text","text":"txt"}]}
14284          ]}
14285        ]}"#;
14286        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14287        let md = adf_to_markdown(&doc).unwrap();
14288        assert!(md.contains("localId=lid-only"), "has localId: {md}");
14289        assert!(!md.contains("params="), "no params: {md}");
14290        let rt = markdown_to_adf(&md).unwrap();
14291        assert_eq!(rt.content[0].local_id.as_deref(), Some("lid-only"));
14292        assert!(rt.content[0].parameters.is_none());
14293    }
14294
14295    #[test]
14296    fn nested_panel_inside_panel() {
14297        let md = ":::panel{type=info}\n:::panel{type=warning}\nInner warning\n:::\n:::";
14298        let adf = markdown_to_adf(md).unwrap();
14299
14300        // Outer panel should exist
14301        assert_eq!(adf.content.len(), 1);
14302        assert_eq!(adf.content[0].node_type, "panel");
14303
14304        // Outer panel should contain an inner panel (not have it truncated)
14305        let panel_content = adf.content[0].content.as_ref().unwrap();
14306        assert!(
14307            panel_content.iter().any(|n| n.node_type == "panel"),
14308            "Outer panel should contain an inner panel, got: {:?}",
14309            panel_content
14310                .iter()
14311                .map(|n| &n.node_type)
14312                .collect::<Vec<_>>()
14313        );
14314    }
14315
14316    #[test]
14317    fn content_after_directive_table_is_preserved() {
14318        // Issue #361: content after a ::::table block was silently dropped
14319        let md = "\
14320## Before table
14321
14322::::table{layout=default}
14323:::tr
14324:::th{}
14325Cell
14326:::
14327:::
14328::::
14329
14330## After table
14331
14332Paragraph after.";
14333        let adf = markdown_to_adf(md).unwrap();
14334        let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
14335        assert_eq!(
14336            types,
14337            vec!["heading", "table", "heading", "paragraph"],
14338            "Content after table was dropped: got {types:?}"
14339        );
14340    }
14341
14342    #[test]
14343    fn paragraph_after_directive_table_is_preserved() {
14344        // Issue #361: minimal reproducer — paragraph after table
14345        let md = "\
14346::::table{layout=default}
14347:::tr
14348:::th{}
14349Header
14350:::
14351:::
14352::::
14353
14354Just a paragraph.";
14355        let adf = markdown_to_adf(md).unwrap();
14356        let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
14357        assert_eq!(
14358            types,
14359            vec!["table", "paragraph"],
14360            "Paragraph after table was dropped: got {types:?}"
14361        );
14362    }
14363
14364    #[test]
14365    fn extension_after_directive_table_is_preserved() {
14366        // Issue #361: extension after table
14367        let md = "\
14368::::table{layout=default}
14369:::tr
14370:::th{}
14371Header
14372:::
14373:::
14374::::
14375
14376::extension{type=com.atlassian.confluence.macro.core key=toc}";
14377        let adf = markdown_to_adf(md).unwrap();
14378        let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
14379        assert_eq!(
14380            types,
14381            vec!["table", "extension"],
14382            "Extension after table was dropped: got {types:?}"
14383        );
14384    }
14385
14386    #[test]
14387    fn multiple_blocks_after_directive_table() {
14388        // Issue #361: multiple blocks after table, including another table
14389        let md = "\
14390## Heading 1
14391
14392::::table{layout=default}
14393:::tr
14394:::td{}
14395A
14396:::
14397:::td{}
14398B
14399:::
14400:::
14401::::
14402
14403## Heading 2
14404
14405Some text.
14406
14407---
14408
14409::::table{layout=default}
14410:::tr
14411:::th{}
14412C
14413:::
14414:::
14415::::
14416
14417## Heading 3";
14418        let adf = markdown_to_adf(md).unwrap();
14419        let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
14420        assert_eq!(
14421            types,
14422            vec![
14423                "heading",
14424                "table",
14425                "heading",
14426                "paragraph",
14427                "rule",
14428                "table",
14429                "heading"
14430            ],
14431            "Content after tables was dropped: got {types:?}"
14432        );
14433    }
14434
14435    // ── Table caption tests (issue #382) ────────────────────────────
14436
14437    #[test]
14438    fn adf_table_caption_to_markdown() {
14439        let doc = AdfDocument {
14440            version: 1,
14441            doc_type: "doc".to_string(),
14442            content: vec![AdfNode::table(vec![
14443                AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
14444                    AdfNode::text("cell"),
14445                ])])]),
14446                AdfNode::caption(vec![AdfNode::text("Table caption")]),
14447            ])],
14448        };
14449        let md = adf_to_markdown(&doc).unwrap();
14450        assert!(
14451            md.contains("::::table"),
14452            "table with caption must use directive form"
14453        );
14454        assert!(
14455            md.contains(":::caption"),
14456            "caption directive missing, got: {md}"
14457        );
14458        assert!(
14459            md.contains("Table caption"),
14460            "caption text missing, got: {md}"
14461        );
14462    }
14463
14464    #[test]
14465    fn directive_table_caption_parses() {
14466        let md = "::::table\n:::tr\n:::td\ncell\n:::\n:::\n:::caption\nTable caption\n:::\n::::\n";
14467        let doc = markdown_to_adf(md).unwrap();
14468        let table = &doc.content[0];
14469        assert_eq!(table.node_type, "table");
14470        let children = table.content.as_ref().unwrap();
14471        assert_eq!(children.len(), 2, "expected row + caption");
14472        assert_eq!(children[0].node_type, "tableRow");
14473        assert_eq!(children[1].node_type, "caption");
14474        let caption_content = children[1].content.as_ref().unwrap();
14475        assert_eq!(caption_content[0].text.as_deref(), Some("Table caption"));
14476    }
14477
14478    #[test]
14479    fn table_caption_round_trip_from_adf_json() {
14480        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
14481          {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
14482          {"type":"caption","content":[{"type":"text","text":"Table caption"}]}
14483        ]}]}"#;
14484        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14485        let md = adf_to_markdown(&doc).unwrap();
14486        assert!(md.contains("Table caption"), "caption text lost in ADF→JFM");
14487        let round_tripped = markdown_to_adf(&md).unwrap();
14488        let children = round_tripped.content[0].content.as_ref().unwrap();
14489        let caption = children.iter().find(|n| n.node_type == "caption");
14490        assert!(caption.is_some(), "caption lost on round-trip");
14491        let caption_text = caption.unwrap().content.as_ref().unwrap();
14492        assert_eq!(caption_text[0].text.as_deref(), Some("Table caption"));
14493    }
14494
14495    #[test]
14496    fn table_caption_with_inline_marks_round_trips() {
14497        let doc = AdfDocument {
14498            version: 1,
14499            doc_type: "doc".to_string(),
14500            content: vec![AdfNode::table(vec![
14501                AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
14502                    AdfNode::text("data"),
14503                ])])]),
14504                AdfNode::caption(vec![
14505                    AdfNode::text("Caption with "),
14506                    AdfNode::text_with_marks("bold", vec![AdfMark::strong()]),
14507                ]),
14508            ])],
14509        };
14510        let md = adf_to_markdown(&doc).unwrap();
14511        assert!(md.contains("**bold**"), "bold mark missing in caption");
14512        let round_tripped = markdown_to_adf(&md).unwrap();
14513        let caption = round_tripped.content[0]
14514            .content
14515            .as_ref()
14516            .unwrap()
14517            .iter()
14518            .find(|n| n.node_type == "caption")
14519            .expect("caption node missing after round-trip");
14520        let inlines = caption.content.as_ref().unwrap();
14521        let bold_node = inlines.iter().find(|n| {
14522            n.marks
14523                .as_ref()
14524                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
14525        });
14526        assert!(bold_node.is_some(), "bold mark lost in caption round-trip");
14527    }
14528
14529    // ── table caption localId tests (issue #524) ──────────────────────
14530
14531    #[test]
14532    fn table_caption_localid_roundtrip() {
14533        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
14534          {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
14535          {"type":"caption","attrs":{"localId":"abcdef123456"},"content":[{"type":"text","text":"Table with localId"}]}
14536        ]}]}"#;
14537        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14538        let md = adf_to_markdown(&doc).unwrap();
14539        assert!(
14540            md.contains("localId=abcdef123456"),
14541            "table caption localId should appear in markdown: {md}"
14542        );
14543        let rt = markdown_to_adf(&md).unwrap();
14544        let caption = rt.content[0]
14545            .content
14546            .as_ref()
14547            .unwrap()
14548            .iter()
14549            .find(|n| n.node_type == "caption")
14550            .expect("caption should survive round-trip");
14551        assert_eq!(
14552            caption.attrs.as_ref().unwrap()["localId"],
14553            "abcdef123456",
14554            "table caption localId should round-trip"
14555        );
14556    }
14557
14558    #[test]
14559    fn table_caption_without_localid_unchanged() {
14560        let md = "::::table\n:::tr\n:::td\ncell\n:::\n:::\n:::caption\nPlain caption\n:::\n::::\n";
14561        let doc = markdown_to_adf(md).unwrap();
14562        let caption = doc.content[0]
14563            .content
14564            .as_ref()
14565            .unwrap()
14566            .iter()
14567            .find(|n| n.node_type == "caption")
14568            .unwrap();
14569        assert!(
14570            caption.attrs.is_none(),
14571            "table caption without localId should not gain attrs"
14572        );
14573        let md2 = adf_to_markdown(&doc).unwrap();
14574        assert!(!md2.contains("localId"), "no localId should appear: {md2}");
14575    }
14576
14577    #[test]
14578    fn table_caption_localid_stripped_when_option_set() {
14579        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
14580          {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
14581          {"type":"caption","attrs":{"localId":"abcdef123456"},"content":[{"type":"text","text":"Stripped"}]}
14582        ]}]}"#;
14583        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14584        let opts = RenderOptions {
14585            strip_local_ids: true,
14586            ..Default::default()
14587        };
14588        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
14589        assert!(
14590            !md.contains("localId"),
14591            "table caption localId should be stripped: {md}"
14592        );
14593    }
14594
14595    #[test]
14596    #[test]
14597    fn tablecell_empty_attrs_preserved_on_roundtrip() {
14598        // Issue #385: tableCell with empty attrs:{} dropped on round-trip
14599        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}]}]}]}"#;
14600        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14601        let md = adf_to_markdown(&doc).unwrap();
14602        let round_tripped = markdown_to_adf(&md).unwrap();
14603        let rows = round_tripped.content[0].content.as_ref().unwrap();
14604        let cell = &rows[0].content.as_ref().unwrap()[0];
14605        assert!(
14606            cell.attrs.is_some(),
14607            "tableCell attrs should be preserved, got None"
14608        );
14609        assert_eq!(
14610            cell.attrs.as_ref().unwrap(),
14611            &serde_json::json!({}),
14612            "tableCell attrs should be an empty object"
14613        );
14614    }
14615
14616    #[test]
14617    fn tablecell_empty_attrs_serialized_in_json() {
14618        // Issue #385: ensure the serialized JSON includes "attrs":{}
14619        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}]}]}]}"#;
14620        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14621        let md = adf_to_markdown(&doc).unwrap();
14622        let round_tripped = markdown_to_adf(&md).unwrap();
14623        let json = serde_json::to_string(&round_tripped).unwrap();
14624        assert!(
14625            json.contains(r#""attrs":{}"#),
14626            "serialized JSON should contain \"attrs\":{{}}, got: {json}"
14627        );
14628    }
14629
14630    #[test]
14631    fn tablecell_empty_attrs_renders_braces_in_markdown() {
14632        // Issue #385: tableCell with empty attrs should render {} prefix in pipe tables
14633        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableHeader","content":[{"type":"paragraph","content":[{"type":"text","text":"H"}]}]},{"type":"tableHeader","content":[{"type":"paragraph","content":[{"type":"text","text":"H2"}]}]}]},{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]},{"type":"tableCell","content":[{"type":"paragraph","content":[{"type":"text","text":"world"}]}]}]}]}]}"#;
14634        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14635        let md = adf_to_markdown(&doc).unwrap();
14636        // Cell with attrs:{} should have {} prefix, cell without attrs should not
14637        assert!(
14638            md.contains("{} hello"),
14639            "cell with empty attrs should render '{{}} hello', got: {md}"
14640        );
14641        assert!(
14642            !md.contains("{} world"),
14643            "cell without attrs should not render '{{}}', got: {md}"
14644        );
14645    }
14646
14647    #[test]
14648    fn tablecell_no_attrs_unchanged_on_roundtrip() {
14649        // Ensure tableCell without attrs stays without attrs
14650        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}]}]}]}"#;
14651        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14652        let md = adf_to_markdown(&doc).unwrap();
14653        let round_tripped = markdown_to_adf(&md).unwrap();
14654        let rows = round_tripped.content[0].content.as_ref().unwrap();
14655        let cell = &rows[0].content.as_ref().unwrap()[0];
14656        assert!(
14657            cell.attrs.is_none(),
14658            "tableCell without attrs should stay None, got: {:?}",
14659            cell.attrs
14660        );
14661    }
14662
14663    #[test]
14664    fn tablecell_nonempty_attrs_preserved_on_roundtrip() {
14665        // Ensure tableCell with non-empty attrs still works
14666        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableHeader","content":[{"type":"paragraph","content":[{"type":"text","text":"H"}]}]}]},{"type":"tableRow","content":[{"type":"tableCell","attrs":{"background":"#DEEBFF","colspan":2},"content":[{"type":"paragraph","content":[{"type":"text","text":"highlighted"}]}]}]}]}]}"##;
14667        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14668        let md = adf_to_markdown(&doc).unwrap();
14669        let round_tripped = markdown_to_adf(&md).unwrap();
14670        let rows = round_tripped.content[0].content.as_ref().unwrap();
14671        let cell = &rows[1].content.as_ref().unwrap()[0];
14672        let attrs = cell.attrs.as_ref().unwrap();
14673        assert_eq!(attrs["background"], "#DEEBFF");
14674        assert_eq!(attrs["colspan"], 2);
14675    }
14676
14677    #[test]
14678    fn pipe_table_not_used_when_caption_present() {
14679        let doc = AdfDocument {
14680            version: 1,
14681            doc_type: "doc".to_string(),
14682            content: vec![AdfNode::table(vec![
14683                AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
14684                    AdfNode::text("H"),
14685                ])])]),
14686                AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
14687                    AdfNode::text("D"),
14688                ])])]),
14689                AdfNode::caption(vec![AdfNode::text("cap")]),
14690            ])],
14691        };
14692        let md = adf_to_markdown(&doc).unwrap();
14693        assert!(
14694            md.contains("::::table"),
14695            "pipe syntax should not be used when caption is present"
14696        );
14697    }
14698
14699    // ── Issue #402: ordered-list-like text in list item hardBreak ──
14700
14701    #[test]
14702    fn hardbreak_with_ordered_marker_in_bullet_item_roundtrips() {
14703        // Issue #402: text starting with "2. " after a hardBreak inside a
14704        // bullet list item must not be re-parsed as a new ordered list.
14705        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
14706          {"type":"listItem","content":[{"type":"paragraph","content":[
14707            {"type":"text","text":"1. First item"},
14708            {"type":"hardBreak"},
14709            {"type":"text","text":"2. Honouring existing commitments"}
14710          ]}]}
14711        ]}]}"#;
14712        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14713        let md = adf_to_markdown(&doc).unwrap();
14714
14715        // The continuation line must be indented so it stays within the list item.
14716        assert!(
14717            md.contains("  2. Honouring"),
14718            "Continuation line should be indented, got:\n{md}"
14719        );
14720
14721        // Round-trip back to ADF
14722        let rt = markdown_to_adf(&md).unwrap();
14723        let list = &rt.content[0];
14724        assert_eq!(list.node_type, "bulletList");
14725        let items = list.content.as_ref().unwrap();
14726        assert_eq!(
14727            items.len(),
14728            1,
14729            "Should be one list item, got {}",
14730            items.len()
14731        );
14732
14733        let para = &items[0].content.as_ref().unwrap()[0];
14734        let inlines = para.content.as_ref().unwrap();
14735        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
14736        assert_eq!(
14737            types,
14738            vec!["text", "hardBreak", "text"],
14739            "Expected text+hardBreak+text, got {types:?}"
14740        );
14741        assert_eq!(
14742            inlines[2].text.as_deref().unwrap(),
14743            "2. Honouring existing commitments"
14744        );
14745    }
14746
14747    #[test]
14748    fn hardbreak_with_ordered_marker_in_ordered_item_roundtrips() {
14749        // Same as above but inside an ordered list.
14750        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
14751          {"type":"listItem","content":[{"type":"paragraph","content":[
14752            {"type":"text","text":"Introduction  "},
14753            {"type":"hardBreak"},
14754            {"type":"text","text":"3. Third point"}
14755          ]}]}
14756        ]}]}"#;
14757        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14758        let md = adf_to_markdown(&doc).unwrap();
14759        let rt = markdown_to_adf(&md).unwrap();
14760
14761        let list = &rt.content[0];
14762        assert_eq!(list.node_type, "orderedList");
14763        let items = list.content.as_ref().unwrap();
14764        assert_eq!(items.len(), 1);
14765
14766        let para = &items[0].content.as_ref().unwrap()[0];
14767        let inlines = para.content.as_ref().unwrap();
14768        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
14769        assert_eq!(types, vec!["text", "hardBreak", "text"]);
14770        assert_eq!(inlines[2].text.as_deref().unwrap(), "3. Third point");
14771    }
14772
14773    #[test]
14774    fn hardbreak_with_bullet_marker_in_bullet_item_roundtrips() {
14775        // Text starting with "- " after a hardBreak must not become a nested bullet list.
14776        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
14777          {"type":"listItem","content":[{"type":"paragraph","content":[
14778            {"type":"text","text":"Header  "},
14779            {"type":"hardBreak"},
14780            {"type":"text","text":"- not a sub-item"}
14781          ]}]}
14782        ]}]}"#;
14783        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14784        let md = adf_to_markdown(&doc).unwrap();
14785        let rt = markdown_to_adf(&md).unwrap();
14786
14787        let list = &rt.content[0];
14788        assert_eq!(list.node_type, "bulletList");
14789        let items = list.content.as_ref().unwrap();
14790        assert_eq!(
14791            items.len(),
14792            1,
14793            "Should be one list item, not {}",
14794            items.len()
14795        );
14796
14797        let para = &items[0].content.as_ref().unwrap()[0];
14798        let inlines = para.content.as_ref().unwrap();
14799        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
14800        assert_eq!(types, vec!["text", "hardBreak", "text"]);
14801        assert_eq!(inlines[2].text.as_deref().unwrap(), "- not a sub-item");
14802    }
14803
14804    #[test]
14805    fn hardbreak_continuation_followed_by_sub_list() {
14806        // A hardBreak continuation line followed by a real sub-list.
14807        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
14808          {"type":"listItem","content":[
14809            {"type":"paragraph","content":[
14810              {"type":"text","text":"Main item  "},
14811              {"type":"hardBreak"},
14812              {"type":"text","text":"continued here"}
14813            ]},
14814            {"type":"bulletList","content":[
14815              {"type":"listItem","content":[{"type":"paragraph","content":[
14816                {"type":"text","text":"sub-item"}
14817              ]}]}
14818            ]}
14819          ]}
14820        ]}]}"#;
14821        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14822        let md = adf_to_markdown(&doc).unwrap();
14823        let rt = markdown_to_adf(&md).unwrap();
14824
14825        let list = &rt.content[0];
14826        let items = list.content.as_ref().unwrap();
14827        assert_eq!(items.len(), 1);
14828
14829        let item_content = items[0].content.as_ref().unwrap();
14830        assert_eq!(item_content.len(), 2, "Expected paragraph + nested list");
14831        assert_eq!(item_content[0].node_type, "paragraph");
14832        assert_eq!(item_content[1].node_type, "bulletList");
14833
14834        // Check the paragraph has hardBreak
14835        let inlines = item_content[0].content.as_ref().unwrap();
14836        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
14837        assert_eq!(types, vec!["text", "hardBreak", "text"]);
14838    }
14839
14840    #[test]
14841    fn multiple_hardbreaks_with_numbered_text_roundtrip() {
14842        // Multiple hardBreaks where each continuation resembles an ordered list.
14843        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
14844          {"type":"listItem","content":[{"type":"paragraph","content":[
14845            {"type":"text","text":"Preamble  "},
14846            {"type":"hardBreak"},
14847            {"type":"text","text":"1. Alpha  "},
14848            {"type":"hardBreak"},
14849            {"type":"text","text":"2. Bravo"}
14850          ]}]}
14851        ]}]}"#;
14852        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14853        let md = adf_to_markdown(&doc).unwrap();
14854        let rt = markdown_to_adf(&md).unwrap();
14855
14856        let items = rt.content[0].content.as_ref().unwrap();
14857        assert_eq!(items.len(), 1);
14858
14859        let inlines = items[0].content.as_ref().unwrap()[0]
14860            .content
14861            .as_ref()
14862            .unwrap();
14863        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
14864        assert_eq!(
14865            types,
14866            vec!["text", "hardBreak", "text", "hardBreak", "text"]
14867        );
14868    }
14869
14870    #[test]
14871    fn trailing_hardbreak_in_bullet_item_roundtrips() {
14872        // A hardBreak as the last inline node with no text after it.
14873        // Exercises the `break` path in the continuation loop and the
14874        // empty-line rendering branch.
14875        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
14876          {"type":"listItem","content":[{"type":"paragraph","content":[
14877            {"type":"text","text":"ends with break"},
14878            {"type":"hardBreak"}
14879          ]}]}
14880        ]}]}"#;
14881        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14882        let md = adf_to_markdown(&doc).unwrap();
14883        let rt = markdown_to_adf(&md).unwrap();
14884
14885        let list = &rt.content[0];
14886        assert_eq!(list.node_type, "bulletList");
14887        let inlines = list.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0]
14888            .content
14889            .as_ref()
14890            .unwrap();
14891        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
14892        assert_eq!(types, vec!["text", "hardBreak"]);
14893    }
14894
14895    #[test]
14896    fn trailing_hardbreak_in_ordered_item_roundtrips() {
14897        // Same as above but in an ordered list, covering the ordered-list
14898        // continuation `break` path.
14899        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
14900          {"type":"listItem","content":[{"type":"paragraph","content":[
14901            {"type":"text","text":"ends with break"},
14902            {"type":"hardBreak"}
14903          ]}]}
14904        ]}]}"#;
14905        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14906        let md = adf_to_markdown(&doc).unwrap();
14907        let rt = markdown_to_adf(&md).unwrap();
14908
14909        let list = &rt.content[0];
14910        assert_eq!(list.node_type, "orderedList");
14911        let inlines = list.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0]
14912            .content
14913            .as_ref()
14914            .unwrap();
14915        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
14916        assert_eq!(types, vec!["text", "hardBreak"]);
14917    }
14918
14919    #[test]
14920    fn trailing_space_hardbreak_continuation_in_bullet_item() {
14921        // Exercises the `ends_with("  ")` path in `has_trailing_hard_break`
14922        // by parsing hand-written markdown that uses trailing-space style
14923        // hardBreaks instead of backslash style.
14924        let md = "- first line  \n  2. continued\n";
14925        let doc = markdown_to_adf(md).unwrap();
14926
14927        let list = &doc.content[0];
14928        assert_eq!(list.node_type, "bulletList");
14929        let items = list.content.as_ref().unwrap();
14930        assert_eq!(
14931            items.len(),
14932            1,
14933            "Should be one list item, got {}",
14934            items.len()
14935        );
14936
14937        let para = &items[0].content.as_ref().unwrap()[0];
14938        let inlines = para.content.as_ref().unwrap();
14939        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
14940        assert_eq!(types, vec!["text", "hardBreak", "text"]);
14941        assert_eq!(inlines[2].text.as_deref().unwrap(), "2. continued");
14942    }
14943
14944    #[test]
14945    fn trailing_space_hardbreak_continuation_in_ordered_item() {
14946        // Same as above but for ordered list, exercising the trailing-space
14947        // path in the ordered-list continuation loop.
14948        let md = "1. first line  \n  - continued\n";
14949        let doc = markdown_to_adf(md).unwrap();
14950
14951        let list = &doc.content[0];
14952        assert_eq!(list.node_type, "orderedList");
14953        let items = list.content.as_ref().unwrap();
14954        assert_eq!(
14955            items.len(),
14956            1,
14957            "Should be one list item, got {}",
14958            items.len()
14959        );
14960
14961        let para = &items[0].content.as_ref().unwrap()[0];
14962        let inlines = para.content.as_ref().unwrap();
14963        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
14964        assert_eq!(types, vec!["text", "hardBreak", "text"]);
14965        assert_eq!(inlines[2].text.as_deref().unwrap(), "- continued");
14966    }
14967
14968    #[test]
14969    fn multi_paragraph_list_item_with_ordered_marker_roundtrips() {
14970        // Issue #402 comment: a listItem with a second paragraph starting
14971        // with "2. " must not become a separate orderedList.
14972        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
14973          {"type":"listItem","content":[
14974            {"type":"paragraph","content":[{"type":"text","text":"some preamble"}]},
14975            {"type":"paragraph","content":[{"type":"text","text":"2. Honouring existing commitments"}]}
14976          ]}
14977        ]}]}"#;
14978        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14979        let md = adf_to_markdown(&doc).unwrap();
14980        let rt = markdown_to_adf(&md).unwrap();
14981
14982        assert_eq!(rt.content.len(), 1, "Should be one top-level block");
14983        let list = &rt.content[0];
14984        assert_eq!(list.node_type, "bulletList");
14985        let items = list.content.as_ref().unwrap();
14986        assert_eq!(items.len(), 1);
14987        let item_content = items[0].content.as_ref().unwrap();
14988        assert_eq!(
14989            item_content.len(),
14990            2,
14991            "Expected 2 paragraphs inside the list item, got {}",
14992            item_content.len()
14993        );
14994        assert_eq!(item_content[0].node_type, "paragraph");
14995        assert_eq!(item_content[1].node_type, "paragraph");
14996        let text = item_content[1].content.as_ref().unwrap()[0]
14997            .text
14998            .as_deref()
14999            .unwrap();
15000        assert_eq!(text, "2. Honouring existing commitments");
15001    }
15002
15003    #[test]
15004    fn multi_paragraph_list_item_with_bullet_marker_roundtrips() {
15005        // Paragraph starting with "- " inside a list item.
15006        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
15007          {"type":"listItem","content":[
15008            {"type":"paragraph","content":[{"type":"text","text":"preamble"}]},
15009            {"type":"paragraph","content":[{"type":"text","text":"- not a sub-item"}]}
15010          ]}
15011        ]}]}"#;
15012        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15013        let md = adf_to_markdown(&doc).unwrap();
15014        let rt = markdown_to_adf(&md).unwrap();
15015
15016        let items = rt.content[0].content.as_ref().unwrap();
15017        assert_eq!(items.len(), 1);
15018        let item_content = items[0].content.as_ref().unwrap();
15019        assert_eq!(item_content.len(), 2);
15020        assert_eq!(item_content[1].node_type, "paragraph");
15021        let text = item_content[1].content.as_ref().unwrap()[0]
15022            .text
15023            .as_deref()
15024            .unwrap();
15025        assert_eq!(text, "- not a sub-item");
15026    }
15027
15028    #[test]
15029    fn backslash_escape_in_inline_text() {
15030        // Verify that `\. ` is unescaped to `. ` in inline parsing.
15031        let nodes = parse_inline(r"2\. text");
15032        assert_eq!(nodes.len(), 1, "Should be one text node");
15033        assert_eq!(nodes[0].text.as_deref().unwrap(), "2. text");
15034    }
15035
15036    #[test]
15037    fn escape_list_marker_ordered() {
15038        assert_eq!(escape_list_marker("2. text"), r"2\. text");
15039        assert_eq!(escape_list_marker("10. tenth"), r"10\. tenth");
15040    }
15041
15042    #[test]
15043    fn escape_list_marker_bullet() {
15044        assert_eq!(escape_list_marker("- text"), r"\- text");
15045        assert_eq!(escape_list_marker("* text"), r"\* text");
15046        assert_eq!(escape_list_marker("+ text"), r"\+ text");
15047    }
15048
15049    #[test]
15050    fn escape_list_marker_plain() {
15051        assert_eq!(escape_list_marker("plain text"), "plain text");
15052        assert_eq!(escape_list_marker("no. marker"), "no. marker");
15053    }
15054
15055    #[test]
15056    fn escape_emoji_shortcodes_basic() {
15057        assert_eq!(escape_emoji_shortcodes(":fire:"), r"\:fire:");
15058        assert_eq!(
15059            escape_emoji_shortcodes("hello :wave: world"),
15060            r"hello \:wave: world"
15061        );
15062    }
15063
15064    #[test]
15065    fn escape_emoji_shortcodes_double_colon() {
15066        // Only the colon that starts `:Active:` needs escaping
15067        assert_eq!(
15068            escape_emoji_shortcodes("Status::Active::Running"),
15069            r"Status:\:Active::Running"
15070        );
15071    }
15072
15073    #[test]
15074    fn escape_emoji_shortcodes_no_match() {
15075        // Lone colons, numeric-only between colons like 10:30
15076        assert_eq!(escape_emoji_shortcodes("Time is 10:30"), "Time is 10:30");
15077        assert_eq!(escape_emoji_shortcodes("no colons here"), "no colons here");
15078        assert_eq!(escape_emoji_shortcodes("trailing:"), "trailing:");
15079        assert_eq!(escape_emoji_shortcodes(":"), ":");
15080    }
15081
15082    #[test]
15083    fn escape_emoji_shortcodes_mixed() {
15084        assert_eq!(
15085            escape_emoji_shortcodes("Alert :fire: on pod:pod42"),
15086            r"Alert \:fire: on pod:pod42"
15087        );
15088    }
15089
15090    #[test]
15091    fn merge_adjacent_text_nodes() {
15092        let mut nodes = vec![AdfNode::text("a"), AdfNode::text("b"), AdfNode::text("c")];
15093        merge_adjacent_text(&mut nodes);
15094        assert_eq!(nodes.len(), 1);
15095        assert_eq!(nodes[0].text.as_deref().unwrap(), "abc");
15096    }
15097
15098    // ── Issue #455: text after hardBreak in paragraph re-parsed as list ──
15099
15100    #[test]
15101    fn issue_455_paragraph_hardbreak_ordered_marker_roundtrips() {
15102        // Issue #455: "1. text" after a hardBreak in a paragraph must not
15103        // become an ordered list.
15104        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
15105          {"type":"text","text":"Introduction: "},
15106          {"type":"hardBreak"},
15107          {"type":"text","text":"1. This text follows a hardBreak"}
15108        ]}]}"#;
15109        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15110        let md = adf_to_markdown(&doc).unwrap();
15111        let rt = markdown_to_adf(&md).unwrap();
15112
15113        assert_eq!(rt.content.len(), 1, "Should remain one block");
15114        assert_eq!(rt.content[0].node_type, "paragraph");
15115        let inlines = rt.content[0].content.as_ref().unwrap();
15116        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
15117        assert_eq!(types, vec!["text", "hardBreak", "text"]);
15118        assert_eq!(
15119            inlines[2].text.as_deref(),
15120            Some("1. This text follows a hardBreak")
15121        );
15122    }
15123
15124    #[test]
15125    fn issue_455_paragraph_hardbreak_bullet_marker_roundtrips() {
15126        // Issue #455 variant: "- text" after a hardBreak in a paragraph.
15127        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
15128          {"type":"text","text":"Intro"},
15129          {"type":"hardBreak"},
15130          {"type":"text","text":"- not a list item"}
15131        ]}]}"#;
15132        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15133        let md = adf_to_markdown(&doc).unwrap();
15134        let rt = markdown_to_adf(&md).unwrap();
15135
15136        assert_eq!(rt.content.len(), 1);
15137        assert_eq!(rt.content[0].node_type, "paragraph");
15138        let inlines = rt.content[0].content.as_ref().unwrap();
15139        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
15140        assert_eq!(types, vec!["text", "hardBreak", "text"]);
15141        assert_eq!(inlines[2].text.as_deref(), Some("- not a list item"));
15142    }
15143
15144    #[test]
15145    fn issue_455_paragraph_hardbreak_heading_marker_roundtrips() {
15146        // Issue #455 variant: "# text" after a hardBreak in a paragraph.
15147        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
15148          {"type":"text","text":"Intro"},
15149          {"type":"hardBreak"},
15150          {"type":"text","text":"# not a heading"}
15151        ]}]}"##;
15152        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15153        let md = adf_to_markdown(&doc).unwrap();
15154        let rt = markdown_to_adf(&md).unwrap();
15155
15156        assert_eq!(rt.content.len(), 1);
15157        assert_eq!(rt.content[0].node_type, "paragraph");
15158        let inlines = rt.content[0].content.as_ref().unwrap();
15159        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
15160        assert_eq!(types, vec!["text", "hardBreak", "text"]);
15161        assert_eq!(inlines[2].text.as_deref(), Some("# not a heading"));
15162    }
15163
15164    #[test]
15165    fn issue_455_paragraph_hardbreak_blockquote_marker_roundtrips() {
15166        // Issue #455 variant: "> text" after a hardBreak in a paragraph.
15167        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
15168          {"type":"text","text":"Intro"},
15169          {"type":"hardBreak"},
15170          {"type":"text","text":"> not a blockquote"}
15171        ]}]}"#;
15172        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15173        let md = adf_to_markdown(&doc).unwrap();
15174        let rt = markdown_to_adf(&md).unwrap();
15175
15176        assert_eq!(rt.content.len(), 1);
15177        assert_eq!(rt.content[0].node_type, "paragraph");
15178        let inlines = rt.content[0].content.as_ref().unwrap();
15179        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
15180        assert_eq!(types, vec!["text", "hardBreak", "text"]);
15181        assert_eq!(inlines[2].text.as_deref(), Some("> not a blockquote"));
15182    }
15183
15184    #[test]
15185    fn issue_455_paragraph_multiple_hardbreaks_with_ordered_markers() {
15186        // Multiple hardBreaks in a paragraph, each followed by "N. text".
15187        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
15188          {"type":"text","text":"Preamble"},
15189          {"type":"hardBreak"},
15190          {"type":"text","text":"1. First"},
15191          {"type":"hardBreak"},
15192          {"type":"text","text":"2. Second"},
15193          {"type":"hardBreak"},
15194          {"type":"text","text":"3. Third"}
15195        ]}]}"#;
15196        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15197        let md = adf_to_markdown(&doc).unwrap();
15198        let rt = markdown_to_adf(&md).unwrap();
15199
15200        assert_eq!(rt.content.len(), 1);
15201        assert_eq!(rt.content[0].node_type, "paragraph");
15202        let inlines = rt.content[0].content.as_ref().unwrap();
15203        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
15204        assert_eq!(
15205            types,
15206            vec![
15207                "text",
15208                "hardBreak",
15209                "text",
15210                "hardBreak",
15211                "text",
15212                "hardBreak",
15213                "text"
15214            ]
15215        );
15216        assert_eq!(inlines[2].text.as_deref(), Some("1. First"));
15217        assert_eq!(inlines[4].text.as_deref(), Some("2. Second"));
15218        assert_eq!(inlines[6].text.as_deref(), Some("3. Third"));
15219    }
15220
15221    #[test]
15222    fn issue_455_paragraph_hardbreak_jfm_indentation() {
15223        // Verify that ADF→JFM output indents continuation lines.
15224        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
15225          {"type":"text","text":"Intro"},
15226          {"type":"hardBreak"},
15227          {"type":"text","text":"1. continued"}
15228        ]}]}"#;
15229        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15230        let md = adf_to_markdown(&doc).unwrap();
15231        assert!(
15232            md.contains("Intro\\\n  1. continued"),
15233            "Continuation should be 2-space-indented, got: {md:?}"
15234        );
15235    }
15236
15237    #[test]
15238    fn issue_455_paragraph_hardbreak_from_jfm() {
15239        // Verify that JFM with 2-space-indented continuation is parsed
15240        // back as a single paragraph with hardBreak.
15241        let md = "Intro\\\n  1. This is continuation text\n";
15242        let doc = markdown_to_adf(md).unwrap();
15243
15244        assert_eq!(doc.content.len(), 1);
15245        assert_eq!(doc.content[0].node_type, "paragraph");
15246        let inlines = doc.content[0].content.as_ref().unwrap();
15247        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
15248        assert_eq!(types, vec!["text", "hardBreak", "text"]);
15249        assert_eq!(
15250            inlines[2].text.as_deref(),
15251            Some("1. This is continuation text")
15252        );
15253    }
15254
15255    #[test]
15256    fn issue_455_paragraph_starts_with_ordered_marker_and_hardbreak() {
15257        // Coverage: first line IS a list marker AND paragraph has hardBreaks.
15258        // Exercises the escape_list_marker path on the first line of a
15259        // multi-line paragraph buf in the rendering code.
15260        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
15261          {"type":"text","text":"1. Starting with a number"},
15262          {"type":"hardBreak"},
15263          {"type":"text","text":"continuation after break"}
15264        ]}]}"#;
15265        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15266        let md = adf_to_markdown(&doc).unwrap();
15267        // First line should be escaped so it's not parsed as ordered list
15268        assert!(
15269            md.contains(r"1\. Starting with a number"),
15270            "First line should have escaped list marker, got: {md:?}"
15271        );
15272        let rt = markdown_to_adf(&md).unwrap();
15273
15274        assert_eq!(rt.content.len(), 1);
15275        assert_eq!(rt.content[0].node_type, "paragraph");
15276        let inlines = rt.content[0].content.as_ref().unwrap();
15277        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
15278        assert_eq!(types, vec!["text", "hardBreak", "text"]);
15279        assert_eq!(
15280            inlines[0].text.as_deref(),
15281            Some("1. Starting with a number")
15282        );
15283        assert_eq!(inlines[2].text.as_deref(), Some("continuation after break"));
15284    }
15285
15286    #[test]
15287    fn ordered_marker_paragraph_in_table_cell_roundtrips() {
15288        // Issue #402: paragraph with "2. " text inside a tableCell must
15289        // not be re-parsed as an ordered list.
15290        let adf_json = r#"{"version":1,"type":"doc","content":[{
15291          "type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},
15292          "content":[{"type":"tableRow","content":[{
15293            "type":"tableCell","attrs":{"colspan":1,"rowspan":1},
15294            "content":[{"type":"paragraph","content":[
15295              {"type":"text","text":"2. Honouring existing commitments"}
15296            ]}]
15297          }]}]
15298        }]}"#;
15299        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15300        let md = adf_to_markdown(&doc).unwrap();
15301        let rt = markdown_to_adf(&md).unwrap();
15302
15303        let table = &rt.content[0];
15304        let cell = &table.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0];
15305        let para = &cell.content.as_ref().unwrap()[0];
15306        assert_eq!(para.node_type, "paragraph");
15307        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
15308        assert_eq!(text, "2. Honouring existing commitments");
15309    }
15310
15311    #[test]
15312    fn bullet_marker_paragraph_standalone_roundtrips() {
15313        // A top-level paragraph starting with "- " must round-trip as
15314        // a paragraph, not a bullet list.
15315        let adf_json = r#"{"version":1,"type":"doc","content":[
15316          {"type":"paragraph","content":[
15317            {"type":"text","text":"- not a list item"}
15318          ]}
15319        ]}"#;
15320        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15321        let md = adf_to_markdown(&doc).unwrap();
15322        assert!(
15323            md.contains(r"\- not a list item"),
15324            "Should escape the leading dash, got:\n{md}"
15325        );
15326        let rt = markdown_to_adf(&md).unwrap();
15327        assert_eq!(rt.content[0].node_type, "paragraph");
15328        let text = rt.content[0].content.as_ref().unwrap()[0]
15329            .text
15330            .as_deref()
15331            .unwrap();
15332        assert_eq!(text, "- not a list item");
15333    }
15334
15335    #[test]
15336    fn merge_adjacent_text_skips_non_text_nodes() {
15337        // Exercises the `else { i += 1 }` branch when adjacent nodes
15338        // are not both plain text.
15339        let mut nodes = vec![
15340            AdfNode::text("a"),
15341            AdfNode::hard_break(),
15342            AdfNode::text("b"),
15343        ];
15344        merge_adjacent_text(&mut nodes);
15345        assert_eq!(nodes.len(), 3);
15346    }
15347
15348    #[test]
15349    fn star_bullet_paragraph_roundtrips() {
15350        // Paragraph starting with "* " must round-trip without becoming
15351        // a bullet list.
15352        let adf_json = r#"{"version":1,"type":"doc","content":[
15353          {"type":"paragraph","content":[
15354            {"type":"text","text":"* starred"}
15355          ]}
15356        ]}"#;
15357        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15358        let md = adf_to_markdown(&doc).unwrap();
15359        let rt = markdown_to_adf(&md).unwrap();
15360        assert_eq!(rt.content[0].node_type, "paragraph");
15361        assert_eq!(
15362            rt.content[0].content.as_ref().unwrap()[0]
15363                .text
15364                .as_deref()
15365                .unwrap(),
15366            "* starred"
15367        );
15368    }
15369
15370    // ---- Issue #388 tests ----
15371
15372    #[test]
15373    fn issue_388_ordered_list_with_strong_hardbreak_roundtrips() {
15374        // Issue #388: orderedList with 2 listItems, each containing
15375        // strong-marked text + hardBreak + plain text.
15376        let adf_json = r#"{"version":1,"type":"doc","content":[
15377          {"type":"orderedList","attrs":{"order":1},"content":[
15378            {"type":"listItem","content":[
15379              {"type":"paragraph","content":[
15380                {"type":"text","text":"Bold heading","marks":[{"type":"strong"}]},
15381                {"type":"hardBreak"},
15382                {"type":"text","text":"Content after break"}
15383              ]}
15384            ]},
15385            {"type":"listItem","content":[
15386              {"type":"paragraph","content":[
15387                {"type":"text","text":"Second item","marks":[{"type":"strong"}]},
15388                {"type":"hardBreak"},
15389                {"type":"text","text":"More content"}
15390              ]}
15391            ]}
15392          ]}
15393        ]}"#;
15394        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15395        let md = adf_to_markdown(&doc).unwrap();
15396        let rt = markdown_to_adf(&md).unwrap();
15397
15398        // Must remain a single orderedList
15399        assert_eq!(
15400            rt.content.len(),
15401            1,
15402            "Should be 1 block (orderedList), got {}",
15403            rt.content.len()
15404        );
15405        assert_eq!(rt.content[0].node_type, "orderedList");
15406        let items = rt.content[0].content.as_ref().unwrap();
15407        assert_eq!(
15408            items.len(),
15409            2,
15410            "Should have 2 listItems, got {}",
15411            items.len()
15412        );
15413
15414        // First item: text(strong) + hardBreak + text
15415        let p1 = items[0].content.as_ref().unwrap()[0]
15416            .content
15417            .as_ref()
15418            .unwrap();
15419        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
15420        assert_eq!(types1, vec!["text", "hardBreak", "text"]);
15421        assert_eq!(p1[0].text.as_deref(), Some("Bold heading"));
15422        assert_eq!(p1[2].text.as_deref(), Some("Content after break"));
15423
15424        // Second item: text(strong) + hardBreak + text
15425        let p2 = items[1].content.as_ref().unwrap()[0]
15426            .content
15427            .as_ref()
15428            .unwrap();
15429        let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
15430        assert_eq!(types2, vec!["text", "hardBreak", "text"]);
15431        assert_eq!(p2[0].text.as_deref(), Some("Second item"));
15432        assert_eq!(p2[2].text.as_deref(), Some("More content"));
15433    }
15434
15435    #[test]
15436    fn issue_388_bullet_list_with_strong_hardbreak_roundtrips() {
15437        // Bullet list variant of issue #388.
15438        let adf_json = r#"{"version":1,"type":"doc","content":[
15439          {"type":"bulletList","content":[
15440            {"type":"listItem","content":[
15441              {"type":"paragraph","content":[
15442                {"type":"text","text":"First","marks":[{"type":"strong"}]},
15443                {"type":"hardBreak"},
15444                {"type":"text","text":"details"}
15445              ]}
15446            ]},
15447            {"type":"listItem","content":[
15448              {"type":"paragraph","content":[
15449                {"type":"text","text":"Second","marks":[{"type":"em"}]},
15450                {"type":"hardBreak"},
15451                {"type":"text","text":"more details"}
15452              ]}
15453            ]}
15454          ]}
15455        ]}"#;
15456        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15457        let md = adf_to_markdown(&doc).unwrap();
15458        let rt = markdown_to_adf(&md).unwrap();
15459
15460        assert_eq!(rt.content.len(), 1);
15461        assert_eq!(rt.content[0].node_type, "bulletList");
15462        let items = rt.content[0].content.as_ref().unwrap();
15463        assert_eq!(items.len(), 2);
15464
15465        let p1 = items[0].content.as_ref().unwrap()[0]
15466            .content
15467            .as_ref()
15468            .unwrap();
15469        assert_eq!(p1[0].text.as_deref(), Some("First"));
15470        assert_eq!(p1[2].text.as_deref(), Some("details"));
15471
15472        let p2 = items[1].content.as_ref().unwrap()[0]
15473            .content
15474            .as_ref()
15475            .unwrap();
15476        assert_eq!(p2[0].text.as_deref(), Some("Second"));
15477        assert_eq!(p2[2].text.as_deref(), Some("more details"));
15478    }
15479
15480    #[test]
15481    fn issue_388_ordered_list_hardbreak_jfm_indentation() {
15482        // Verify the JFM output has properly indented continuation lines.
15483        let adf_json = r#"{"version":1,"type":"doc","content":[
15484          {"type":"orderedList","attrs":{"order":1},"content":[
15485            {"type":"listItem","content":[
15486              {"type":"paragraph","content":[
15487                {"type":"text","text":"heading","marks":[{"type":"strong"}]},
15488                {"type":"hardBreak"},
15489                {"type":"text","text":"body"}
15490              ]}
15491            ]}
15492          ]}
15493        ]}"#;
15494        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15495        let md = adf_to_markdown(&doc).unwrap();
15496        assert!(
15497            md.contains("1. **heading**\\\n  body"),
15498            "Continuation should be indented, got:\n{md}"
15499        );
15500    }
15501
15502    #[test]
15503    fn issue_388_ordered_list_hardbreak_from_jfm() {
15504        // Direct JFM → ADF: ordered list with hardBreak continuation.
15505        let md = "1. **bold**\\\n  continued\n2. **also bold**\\\n  also continued\n";
15506        let doc = markdown_to_adf(md).unwrap();
15507
15508        assert_eq!(doc.content.len(), 1);
15509        assert_eq!(doc.content[0].node_type, "orderedList");
15510        let items = doc.content[0].content.as_ref().unwrap();
15511        assert_eq!(items.len(), 2);
15512
15513        let p1 = items[0].content.as_ref().unwrap()[0]
15514            .content
15515            .as_ref()
15516            .unwrap();
15517        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
15518        assert_eq!(types1, vec!["text", "hardBreak", "text"]);
15519        assert_eq!(p1[0].text.as_deref(), Some("bold"));
15520        assert_eq!(p1[2].text.as_deref(), Some("continued"));
15521
15522        let p2 = items[1].content.as_ref().unwrap()[0]
15523            .content
15524            .as_ref()
15525            .unwrap();
15526        let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
15527        assert_eq!(types2, vec!["text", "hardBreak", "text"]);
15528    }
15529
15530    #[test]
15531    fn issue_388_bullet_list_hardbreak_from_jfm() {
15532        // Direct JFM → ADF: bullet list with hardBreak continuation.
15533        let md = "- first\\\n  second\n- third\\\n  fourth\n";
15534        let doc = markdown_to_adf(md).unwrap();
15535
15536        assert_eq!(doc.content.len(), 1);
15537        assert_eq!(doc.content[0].node_type, "bulletList");
15538        let items = doc.content[0].content.as_ref().unwrap();
15539        assert_eq!(items.len(), 2);
15540
15541        for (i, expected) in [("first", "second"), ("third", "fourth")]
15542            .iter()
15543            .enumerate()
15544        {
15545            let p = items[i].content.as_ref().unwrap()[0]
15546                .content
15547                .as_ref()
15548                .unwrap();
15549            let types: Vec<&str> = p.iter().map(|n| n.node_type.as_str()).collect();
15550            assert_eq!(types, vec!["text", "hardBreak", "text"]);
15551            assert_eq!(p[0].text.as_deref(), Some(expected.0));
15552            assert_eq!(p[2].text.as_deref(), Some(expected.1));
15553        }
15554    }
15555
15556    #[test]
15557    fn issue_433_heading_hardbreak_roundtrips() {
15558        // Issue #433: hardBreak inside heading splits into heading + paragraph.
15559        let adf_json = r#"{"version":1,"type":"doc","content":[{
15560          "type":"heading",
15561          "attrs":{"level":1},
15562          "content":[
15563            {"type":"text","text":"Line one"},
15564            {"type":"hardBreak"},
15565            {"type":"text","text":"Line two"}
15566          ]
15567        }]}"#;
15568        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15569        let md = adf_to_markdown(&doc).unwrap();
15570        let rt = markdown_to_adf(&md).unwrap();
15571
15572        assert_eq!(
15573            rt.content.len(),
15574            1,
15575            "Should remain a single heading, got {} blocks",
15576            rt.content.len()
15577        );
15578        assert_eq!(rt.content[0].node_type, "heading");
15579        let inlines = rt.content[0].content.as_ref().unwrap();
15580        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
15581        assert_eq!(
15582            types,
15583            vec!["text", "hardBreak", "text"],
15584            "hardBreak should be preserved, got: {types:?}"
15585        );
15586        assert_eq!(inlines[0].text.as_deref(), Some("Line one"));
15587        assert_eq!(inlines[2].text.as_deref(), Some("Line two"));
15588    }
15589
15590    #[test]
15591    fn issue_433_heading_hardbreak_jfm_indentation() {
15592        // Verify the JFM output has properly indented continuation lines.
15593        let adf_json = r#"{"version":1,"type":"doc","content":[{
15594          "type":"heading",
15595          "attrs":{"level":2},
15596          "content":[
15597            {"type":"text","text":"Title"},
15598            {"type":"hardBreak"},
15599            {"type":"text","text":"Subtitle"}
15600          ]
15601        }]}"#;
15602        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15603        let md = adf_to_markdown(&doc).unwrap();
15604        assert!(
15605            md.contains("## Title\\\n  Subtitle"),
15606            "Continuation should be indented, got:\n{md}"
15607        );
15608    }
15609
15610    #[test]
15611    fn issue_433_heading_hardbreak_from_jfm() {
15612        // Direct JFM → ADF: heading with hardBreak continuation.
15613        let md = "# First\\\n  Second\n";
15614        let doc = markdown_to_adf(md).unwrap();
15615
15616        assert_eq!(doc.content.len(), 1);
15617        assert_eq!(doc.content[0].node_type, "heading");
15618        let inlines = doc.content[0].content.as_ref().unwrap();
15619        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
15620        assert_eq!(types, vec!["text", "hardBreak", "text"]);
15621        assert_eq!(inlines[0].text.as_deref(), Some("First"));
15622        assert_eq!(inlines[2].text.as_deref(), Some("Second"));
15623    }
15624
15625    #[test]
15626    fn issue_433_heading_consecutive_hardbreaks_roundtrip() {
15627        // Consecutive hardBreaks in a heading.
15628        let adf_json = r#"{"version":1,"type":"doc","content":[{
15629          "type":"heading",
15630          "attrs":{"level":3},
15631          "content":[
15632            {"type":"text","text":"A"},
15633            {"type":"hardBreak"},
15634            {"type":"hardBreak"},
15635            {"type":"text","text":"B"}
15636          ]
15637        }]}"#;
15638        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15639        let md = adf_to_markdown(&doc).unwrap();
15640        let rt = markdown_to_adf(&md).unwrap();
15641
15642        assert_eq!(rt.content.len(), 1, "Should remain a single heading");
15643        assert_eq!(rt.content[0].node_type, "heading");
15644        let inlines = rt.content[0].content.as_ref().unwrap();
15645        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
15646        assert_eq!(types, vec!["text", "hardBreak", "hardBreak", "text"]);
15647    }
15648
15649    #[test]
15650    fn issue_433_heading_with_strong_and_hardbreak_roundtrips() {
15651        // Heading with strong-marked text + hardBreak + plain text.
15652        let adf_json = r#"{"version":1,"type":"doc","content":[{
15653          "type":"heading",
15654          "attrs":{"level":1},
15655          "content":[
15656            {"type":"text","text":"Bold title","marks":[{"type":"strong"}]},
15657            {"type":"hardBreak"},
15658            {"type":"text","text":"plain continuation"}
15659          ]
15660        }]}"#;
15661        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15662        let md = adf_to_markdown(&doc).unwrap();
15663        let rt = markdown_to_adf(&md).unwrap();
15664
15665        assert_eq!(rt.content.len(), 1);
15666        assert_eq!(rt.content[0].node_type, "heading");
15667        let inlines = rt.content[0].content.as_ref().unwrap();
15668        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
15669        assert_eq!(types, vec!["text", "hardBreak", "text"]);
15670        assert_eq!(inlines[0].text.as_deref(), Some("Bold title"));
15671        assert_eq!(inlines[2].text.as_deref(), Some("plain continuation"));
15672    }
15673
15674    #[test]
15675    fn issue_433_heading_with_link_and_hardbreak_roundtrips() {
15676        // Real-world pattern: heading with link + hardBreak + text.
15677        let adf_json = r#"{"version":1,"type":"doc","content":[{
15678          "type":"heading",
15679          "attrs":{"level":1},
15680          "content":[
15681            {"type":"text","text":"Click here","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
15682            {"type":"hardBreak"},
15683            {"type":"text","text":"Subtitle text"}
15684          ]
15685        }]}"#;
15686        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15687        let md = adf_to_markdown(&doc).unwrap();
15688        let rt = markdown_to_adf(&md).unwrap();
15689
15690        assert_eq!(rt.content.len(), 1);
15691        assert_eq!(rt.content[0].node_type, "heading");
15692        let inlines = rt.content[0].content.as_ref().unwrap();
15693        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
15694        assert_eq!(types, vec!["text", "hardBreak", "text"]);
15695        assert_eq!(inlines[2].text.as_deref(), Some("Subtitle text"));
15696    }
15697
15698    #[test]
15699    fn has_trailing_hard_break_backslash() {
15700        assert!(has_trailing_hard_break("text\\"));
15701        assert!(has_trailing_hard_break("**bold**\\"));
15702    }
15703
15704    #[test]
15705    fn has_trailing_hard_break_trailing_spaces() {
15706        assert!(has_trailing_hard_break("text  "));
15707        assert!(has_trailing_hard_break("word   "));
15708    }
15709
15710    #[test]
15711    fn has_trailing_hard_break_false() {
15712        assert!(!has_trailing_hard_break("plain text"));
15713        assert!(!has_trailing_hard_break("text "));
15714        assert!(!has_trailing_hard_break(""));
15715    }
15716
15717    #[test]
15718    fn collect_hardbreak_continuations_collects_indented() {
15719        // A line ending with `\` followed by 2-space-indented continuation.
15720        // Only one line is collected because the result no longer ends with `\`.
15721        let input = "first\\\n  second\n  third\n";
15722        let mut parser = MarkdownParser::new(input);
15723        parser.advance(); // skip first line
15724        let mut text = "first\\".to_string();
15725        parser.collect_hardbreak_continuations(&mut text);
15726        assert_eq!(text, "first\\\nsecond");
15727    }
15728
15729    #[test]
15730    fn collect_hardbreak_continuations_stops_at_non_indented() {
15731        let input = "first\\\nnot indented\n";
15732        let mut parser = MarkdownParser::new(input);
15733        parser.advance();
15734        let mut text = "first\\".to_string();
15735        parser.collect_hardbreak_continuations(&mut text);
15736        // Should NOT collect the non-indented line
15737        assert_eq!(text, "first\\");
15738    }
15739
15740    #[test]
15741    fn collect_hardbreak_continuations_no_trailing_break() {
15742        // If the text doesn't end with a hardBreak marker, nothing is collected.
15743        let input = "plain\n  indented\n";
15744        let mut parser = MarkdownParser::new(input);
15745        parser.advance();
15746        let mut text = "plain".to_string();
15747        parser.collect_hardbreak_continuations(&mut text);
15748        assert_eq!(text, "plain");
15749    }
15750
15751    #[test]
15752    fn collect_hardbreak_continuations_chained() {
15753        // Multiple continuation lines chained via repeated hardBreaks.
15754        let input = "a\\\n  b\\\n  c\\\n  d\n";
15755        let mut parser = MarkdownParser::new(input);
15756        parser.advance();
15757        let mut text = "a\\".to_string();
15758        parser.collect_hardbreak_continuations(&mut text);
15759        assert_eq!(text, "a\\\nb\\\nc\\\nd");
15760    }
15761
15762    #[test]
15763    fn collect_hardbreak_continuations_stops_before_image_line() {
15764        // An indented continuation that starts with `![` (mediaSingle syntax)
15765        // must NOT be swallowed as a paragraph continuation (issue #490).
15766        let input = "text\\\n  ![](url){type=file id=x}\n";
15767        let mut parser = MarkdownParser::new(input);
15768        parser.advance(); // skip first line
15769        let mut text = "text\\".to_string();
15770        parser.collect_hardbreak_continuations(&mut text);
15771        // The image line should NOT have been consumed.
15772        assert_eq!(text, "text\\");
15773        // Parser should still be on the image line (not past it).
15774        assert!(!parser.at_end());
15775        assert!(parser.current_line().contains("![](url)"));
15776    }
15777
15778    #[test]
15779    fn ordered_list_with_sub_content_after_hardbreak() {
15780        // Exercises the sub-content collection loop in parse_ordered_list
15781        // (lines 339-347) with a hardBreak item that also has a nested list.
15782        let adf_json = r#"{"version":1,"type":"doc","content":[
15783          {"type":"orderedList","attrs":{"order":1},"content":[
15784            {"type":"listItem","content":[
15785              {"type":"paragraph","content":[
15786                {"type":"text","text":"parent"},
15787                {"type":"hardBreak"},
15788                {"type":"text","text":"continued"}
15789              ]},
15790              {"type":"bulletList","content":[
15791                {"type":"listItem","content":[
15792                  {"type":"paragraph","content":[
15793                    {"type":"text","text":"child"}
15794                  ]}
15795                ]}
15796              ]}
15797            ]}
15798          ]}
15799        ]}"#;
15800        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15801        let md = adf_to_markdown(&doc).unwrap();
15802        let rt = markdown_to_adf(&md).unwrap();
15803
15804        assert_eq!(rt.content.len(), 1);
15805        assert_eq!(rt.content[0].node_type, "orderedList");
15806        let item_content = rt.content[0].content.as_ref().unwrap()[0]
15807            .content
15808            .as_ref()
15809            .unwrap();
15810        // Paragraph with hardBreak
15811        let p = item_content[0].content.as_ref().unwrap();
15812        let types: Vec<&str> = p.iter().map(|n| n.node_type.as_str()).collect();
15813        assert_eq!(types, vec!["text", "hardBreak", "text"]);
15814        assert_eq!(p[0].text.as_deref(), Some("parent"));
15815        assert_eq!(p[2].text.as_deref(), Some("continued"));
15816        // Nested bullet list
15817        assert_eq!(item_content[1].node_type, "bulletList");
15818    }
15819
15820    #[test]
15821    fn render_list_item_content_no_content() {
15822        // A listItem with content: None should produce just a newline.
15823        let item = AdfNode {
15824            node_type: "listItem".to_string(),
15825            attrs: None,
15826            content: None,
15827            text: None,
15828            marks: None,
15829            local_id: None,
15830            parameters: None,
15831        };
15832        let mut output = String::new();
15833        let opts = RenderOptions::default();
15834        render_list_item_content(&item, &mut output, &opts);
15835        assert_eq!(output, "\n");
15836    }
15837
15838    #[test]
15839    fn render_list_item_content_empty_content() {
15840        // A listItem with content: Some(vec![]) should produce just a newline.
15841        let item = AdfNode::list_item(vec![]);
15842        let mut output = String::new();
15843        let opts = RenderOptions::default();
15844        render_list_item_content(&item, &mut output, &opts);
15845        assert_eq!(output, "\n");
15846    }
15847
15848    #[test]
15849    fn plus_bullet_paragraph_roundtrips() {
15850        // Paragraph starting with "+ " must round-trip without becoming
15851        // a bullet list.
15852        let adf_json = r#"{"version":1,"type":"doc","content":[
15853          {"type":"paragraph","content":[
15854            {"type":"text","text":"+ plus"}
15855          ]}
15856        ]}"#;
15857        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15858        let md = adf_to_markdown(&doc).unwrap();
15859        let rt = markdown_to_adf(&md).unwrap();
15860        assert_eq!(rt.content[0].node_type, "paragraph");
15861        assert_eq!(
15862            rt.content[0].content.as_ref().unwrap()[0]
15863                .text
15864                .as_deref()
15865                .unwrap(),
15866            "+ plus"
15867        );
15868    }
15869
15870    // ---- Issue #430 tests: mediaSingle inside listItem ----
15871
15872    #[test]
15873    fn issue_430_file_media_in_bullet_list_roundtrip() {
15874        // Issue #430: mediaSingle (type:file) as direct child of listItem
15875        // in a bulletList must survive round-trip.
15876        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
15877          {"type":"listItem","content":[{
15878            "type":"mediaSingle",
15879            "attrs":{"layout":"center","width":1009,"widthType":"pixel"},
15880            "content":[{
15881              "type":"media",
15882              "attrs":{"collection":"contentId-123","height":576,"id":"00066e8e-554e-4d7e-af59-a0ef2888bdb6","type":"file","width":1009}
15883            }]
15884          }]}
15885        ]}]}"#;
15886        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15887        let md = adf_to_markdown(&doc).unwrap();
15888        let rt = markdown_to_adf(&md).unwrap();
15889
15890        let list = &rt.content[0];
15891        assert_eq!(list.node_type, "bulletList");
15892        let item = &list.content.as_ref().unwrap()[0];
15893        assert_eq!(item.node_type, "listItem");
15894        let ms = &item.content.as_ref().unwrap()[0];
15895        assert_eq!(ms.node_type, "mediaSingle");
15896        let ms_attrs = ms.attrs.as_ref().unwrap();
15897        assert_eq!(ms_attrs["layout"], "center");
15898        assert_eq!(ms_attrs["width"], 1009);
15899        assert_eq!(ms_attrs["widthType"], "pixel");
15900        let media = &ms.content.as_ref().unwrap()[0];
15901        assert_eq!(media.node_type, "media");
15902        let m_attrs = media.attrs.as_ref().unwrap();
15903        assert_eq!(m_attrs["type"], "file");
15904        assert_eq!(m_attrs["id"], "00066e8e-554e-4d7e-af59-a0ef2888bdb6");
15905        assert_eq!(m_attrs["collection"], "contentId-123");
15906        assert_eq!(m_attrs["height"], 576);
15907        assert_eq!(m_attrs["width"], 1009);
15908    }
15909
15910    #[test]
15911    fn issue_430_file_media_in_ordered_list_roundtrip() {
15912        // Same as above but inside an orderedList.
15913        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
15914          {"type":"listItem","content":[{
15915            "type":"mediaSingle",
15916            "attrs":{"layout":"center"},
15917            "content":[{
15918              "type":"media",
15919              "attrs":{"type":"file","id":"abc-123","collection":"contentId-456","height":100,"width":200}
15920            }]
15921          }]}
15922        ]}]}"#;
15923        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15924        let md = adf_to_markdown(&doc).unwrap();
15925        let rt = markdown_to_adf(&md).unwrap();
15926
15927        let list = &rt.content[0];
15928        assert_eq!(list.node_type, "orderedList");
15929        let item = &list.content.as_ref().unwrap()[0];
15930        assert_eq!(item.node_type, "listItem");
15931        let ms = &item.content.as_ref().unwrap()[0];
15932        assert_eq!(ms.node_type, "mediaSingle");
15933        let media = &ms.content.as_ref().unwrap()[0];
15934        assert_eq!(media.node_type, "media");
15935        let m_attrs = media.attrs.as_ref().unwrap();
15936        assert_eq!(m_attrs["type"], "file");
15937        assert_eq!(m_attrs["id"], "abc-123");
15938        assert_eq!(m_attrs["collection"], "contentId-456");
15939    }
15940
15941    #[test]
15942    fn issue_430_external_media_in_bullet_list_roundtrip() {
15943        // External image (type:external) inside a bullet list item.
15944        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
15945          {"type":"listItem","content":[{
15946            "type":"mediaSingle",
15947            "attrs":{"layout":"center"},
15948            "content":[{
15949              "type":"media",
15950              "attrs":{"type":"external","url":"https://example.com/img.png","alt":"Photo"}
15951            }]
15952          }]}
15953        ]}]}"#;
15954        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15955        let md = adf_to_markdown(&doc).unwrap();
15956        let rt = markdown_to_adf(&md).unwrap();
15957
15958        let list = &rt.content[0];
15959        assert_eq!(list.node_type, "bulletList");
15960        let item = &list.content.as_ref().unwrap()[0];
15961        let ms = &item.content.as_ref().unwrap()[0];
15962        assert_eq!(ms.node_type, "mediaSingle");
15963        let media = &ms.content.as_ref().unwrap()[0];
15964        assert_eq!(media.node_type, "media");
15965        let m_attrs = media.attrs.as_ref().unwrap();
15966        assert_eq!(m_attrs["type"], "external");
15967        assert_eq!(m_attrs["url"], "https://example.com/img.png");
15968    }
15969
15970    #[test]
15971    fn issue_430_media_with_paragraph_siblings_in_list_item() {
15972        // listItem containing a paragraph followed by a mediaSingle.
15973        // Both children must survive round-trip.
15974        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
15975          {"type":"listItem","content":[
15976            {"type":"paragraph","content":[{"type":"text","text":"Caption:"}]},
15977            {"type":"mediaSingle","attrs":{"layout":"center"},
15978             "content":[{"type":"media","attrs":{"type":"file","id":"img-001","collection":"col-1","height":50,"width":100}}]}
15979          ]}
15980        ]}]}"#;
15981        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15982        let md = adf_to_markdown(&doc).unwrap();
15983        let rt = markdown_to_adf(&md).unwrap();
15984
15985        let item = &rt.content[0].content.as_ref().unwrap()[0];
15986        let children = item.content.as_ref().unwrap();
15987        assert_eq!(children.len(), 2, "expected 2 children in listItem");
15988        assert_eq!(children[0].node_type, "paragraph");
15989        assert_eq!(children[1].node_type, "mediaSingle");
15990        let media = &children[1].content.as_ref().unwrap()[0];
15991        assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-001");
15992    }
15993
15994    #[test]
15995    fn issue_430_multiple_media_in_list_items() {
15996        // Multiple list items each containing mediaSingle.
15997        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
15998          {"type":"listItem","content":[{
15999            "type":"mediaSingle","attrs":{"layout":"center"},
16000            "content":[{"type":"media","attrs":{"type":"file","id":"img-a","collection":"c1","height":10,"width":20}}]
16001          }]},
16002          {"type":"listItem","content":[{
16003            "type":"mediaSingle","attrs":{"layout":"center"},
16004            "content":[{"type":"media","attrs":{"type":"file","id":"img-b","collection":"c2","height":30,"width":40}}]
16005          }]}
16006        ]}]}"#;
16007        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16008        let md = adf_to_markdown(&doc).unwrap();
16009        let rt = markdown_to_adf(&md).unwrap();
16010
16011        let items = rt.content[0].content.as_ref().unwrap();
16012        assert_eq!(items.len(), 2);
16013        for (i, expected_id) in [("img-a", "c1"), ("img-b", "c2")].iter().enumerate() {
16014            let ms = &items[i].content.as_ref().unwrap()[0];
16015            assert_eq!(ms.node_type, "mediaSingle");
16016            let m_attrs = ms.content.as_ref().unwrap()[0].attrs.as_ref().unwrap();
16017            assert_eq!(m_attrs["id"], expected_id.0);
16018            assert_eq!(m_attrs["collection"], expected_id.1);
16019        }
16020    }
16021
16022    #[test]
16023    fn issue_430_jfm_to_adf_media_in_bullet_item() {
16024        // Parse JFM directly: image syntax on the first line of a bullet item
16025        // must produce mediaSingle, not a paragraph with corrupted text.
16026        let md = "- ![](){type=file id=test-id collection=col-1 height=100 width=200}\n";
16027        let doc = markdown_to_adf(md).unwrap();
16028
16029        let list = &doc.content[0];
16030        assert_eq!(list.node_type, "bulletList");
16031        let item = &list.content.as_ref().unwrap()[0];
16032        let ms = &item.content.as_ref().unwrap()[0];
16033        assert_eq!(
16034            ms.node_type, "mediaSingle",
16035            "expected mediaSingle, got {}",
16036            ms.node_type
16037        );
16038        let media = &ms.content.as_ref().unwrap()[0];
16039        assert_eq!(media.node_type, "media");
16040        let m_attrs = media.attrs.as_ref().unwrap();
16041        assert_eq!(m_attrs["type"], "file");
16042        assert_eq!(m_attrs["id"], "test-id");
16043    }
16044
16045    #[test]
16046    fn issue_430_jfm_to_adf_media_in_ordered_item() {
16047        // Parse JFM directly: image syntax on the first line of an ordered list item.
16048        let md = "1. ![alt text](https://example.com/photo.jpg)\n";
16049        let doc = markdown_to_adf(md).unwrap();
16050
16051        let list = &doc.content[0];
16052        assert_eq!(list.node_type, "orderedList");
16053        let item = &list.content.as_ref().unwrap()[0];
16054        let ms = &item.content.as_ref().unwrap()[0];
16055        assert_eq!(
16056            ms.node_type, "mediaSingle",
16057            "expected mediaSingle, got {}",
16058            ms.node_type
16059        );
16060    }
16061
16062    #[test]
16063    fn issue_430_media_then_paragraph_in_bullet_list_roundtrip() {
16064        // listItem with mediaSingle as first child followed by a paragraph.
16065        // Exercises the sub_lines non-empty path when first_node is mediaSingle.
16066        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
16067          {"type":"listItem","content":[
16068            {"type":"mediaSingle","attrs":{"layout":"center"},
16069             "content":[{"type":"media","attrs":{"type":"file","id":"img-first","collection":"col-1","height":50,"width":100}}]},
16070            {"type":"paragraph","content":[{"type":"text","text":"Caption below"}]}
16071          ]}
16072        ]}]}"#;
16073        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16074        let md = adf_to_markdown(&doc).unwrap();
16075        let rt = markdown_to_adf(&md).unwrap();
16076
16077        let item = &rt.content[0].content.as_ref().unwrap()[0];
16078        let children = item.content.as_ref().unwrap();
16079        assert_eq!(children.len(), 2, "expected 2 children in listItem");
16080        assert_eq!(children[0].node_type, "mediaSingle");
16081        let media = &children[0].content.as_ref().unwrap()[0];
16082        assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-first");
16083        assert_eq!(children[1].node_type, "paragraph");
16084    }
16085
16086    #[test]
16087    fn issue_430_media_then_paragraph_in_ordered_list_roundtrip() {
16088        // Same as above but for ordered lists.
16089        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
16090          {"type":"listItem","content":[
16091            {"type":"mediaSingle","attrs":{"layout":"center"},
16092             "content":[{"type":"media","attrs":{"type":"file","id":"img-ord","collection":"col-2","height":60,"width":120}}]},
16093            {"type":"paragraph","content":[{"type":"text","text":"Description"}]}
16094          ]}
16095        ]}]}"#;
16096        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16097        let md = adf_to_markdown(&doc).unwrap();
16098        let rt = markdown_to_adf(&md).unwrap();
16099
16100        let item = &rt.content[0].content.as_ref().unwrap()[0];
16101        let children = item.content.as_ref().unwrap();
16102        assert_eq!(children.len(), 2, "expected 2 children in listItem");
16103        assert_eq!(children[0].node_type, "mediaSingle");
16104        assert_eq!(children[1].node_type, "paragraph");
16105    }
16106
16107    #[test]
16108    fn issue_430_external_media_with_width_type_roundtrip() {
16109        // External image with widthType attr must survive round-trip.
16110        let adf_json = r#"{"version":1,"type":"doc","content":[{
16111          "type":"mediaSingle",
16112          "attrs":{"layout":"wide","width":800,"widthType":"pixel"},
16113          "content":[{
16114            "type":"media",
16115            "attrs":{"type":"external","url":"https://example.com/photo.png","alt":"wide photo"}
16116          }]
16117        }]}"#;
16118        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16119        let md = adf_to_markdown(&doc).unwrap();
16120        assert!(
16121            md.contains("widthType=pixel"),
16122            "expected widthType=pixel in markdown, got: {md}"
16123        );
16124        let rt = markdown_to_adf(&md).unwrap();
16125        let ms = &rt.content[0];
16126        assert_eq!(ms.node_type, "mediaSingle");
16127        let ms_attrs = ms.attrs.as_ref().unwrap();
16128        assert_eq!(ms_attrs["widthType"], "pixel");
16129        assert_eq!(ms_attrs["width"], 800);
16130        assert_eq!(ms_attrs["layout"], "wide");
16131    }
16132
16133    // ── Issue #490: mediaSingle after hardBreak in listItem ─────
16134
16135    #[test]
16136    fn issue_490_paragraph_with_hardbreak_then_media_single_roundtrip() {
16137        // Reproducer from issue #490: paragraph with trailing hardBreak
16138        // followed by mediaSingle inside a listItem.
16139        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
16140          {"type":"listItem","content":[
16141            {"type":"paragraph","content":[
16142              {"type":"text","text":"Item with image:"},
16143              {"type":"hardBreak"}
16144            ]},
16145            {"type":"mediaSingle","attrs":{"layout":"center","width":400,"widthType":"pixel"},
16146             "content":[{"type":"media","attrs":{
16147               "id":"aabbccdd-1234-5678-abcd-aabbccdd1234",
16148               "type":"file",
16149               "collection":"contentId-123456",
16150               "width":800,
16151               "height":600
16152             }}]}
16153          ]}
16154        ]}]}"#;
16155        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16156        let md = adf_to_markdown(&doc).unwrap();
16157        let rt = markdown_to_adf(&md).unwrap();
16158
16159        let item = &rt.content[0].content.as_ref().unwrap()[0];
16160        let children = item.content.as_ref().unwrap();
16161        assert_eq!(children.len(), 2, "expected 2 children in listItem");
16162        assert_eq!(children[0].node_type, "paragraph");
16163        assert_eq!(
16164            children[1].node_type, "mediaSingle",
16165            "expected mediaSingle, got {:?}",
16166            children[1].node_type
16167        );
16168        let media = &children[1].content.as_ref().unwrap()[0];
16169        let m_attrs = media.attrs.as_ref().unwrap();
16170        assert_eq!(m_attrs["id"], "aabbccdd-1234-5678-abcd-aabbccdd1234");
16171        assert_eq!(m_attrs["collection"], "contentId-123456");
16172        assert_eq!(m_attrs["height"], 600);
16173        assert_eq!(m_attrs["width"], 800);
16174    }
16175
16176    #[test]
16177    fn issue_490_paragraph_with_hardbreak_then_media_single_ordered_list() {
16178        // Same scenario but in an ordered list.
16179        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
16180          {"type":"listItem","content":[
16181            {"type":"paragraph","content":[
16182              {"type":"text","text":"Step with screenshot:"},
16183              {"type":"hardBreak"}
16184            ]},
16185            {"type":"mediaSingle","attrs":{"layout":"center"},
16186             "content":[{"type":"media","attrs":{
16187               "id":"ord-media-id","type":"file","collection":"col-ord","width":640,"height":480
16188             }}]}
16189          ]}
16190        ]}]}"#;
16191        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16192        let md = adf_to_markdown(&doc).unwrap();
16193        let rt = markdown_to_adf(&md).unwrap();
16194
16195        let item = &rt.content[0].content.as_ref().unwrap()[0];
16196        let children = item.content.as_ref().unwrap();
16197        assert_eq!(children.len(), 2, "expected 2 children in listItem");
16198        assert_eq!(children[0].node_type, "paragraph");
16199        assert_eq!(children[1].node_type, "mediaSingle");
16200        let media = &children[1].content.as_ref().unwrap()[0];
16201        assert_eq!(media.attrs.as_ref().unwrap()["id"], "ord-media-id");
16202    }
16203
16204    #[test]
16205    fn issue_490_hardbreak_continuation_does_not_swallow_media_line() {
16206        // Directly tests that collect_hardbreak_continuations stops before
16207        // an indented mediaSingle line.
16208        let md = "- Item with image:\\\n  ![](){type=file id=test-490 collection=col height=100 width=200}\n";
16209        let doc = markdown_to_adf(md).unwrap();
16210
16211        let item = &doc.content[0].content.as_ref().unwrap()[0];
16212        let children = item.content.as_ref().unwrap();
16213        assert_eq!(children.len(), 2, "expected 2 children in listItem");
16214        assert_eq!(children[0].node_type, "paragraph");
16215        assert_eq!(
16216            children[1].node_type, "mediaSingle",
16217            "expected mediaSingle as second child, got {:?}",
16218            children[1].node_type
16219        );
16220        let media = &children[1].content.as_ref().unwrap()[0];
16221        assert_eq!(media.attrs.as_ref().unwrap()["id"], "test-490");
16222    }
16223
16224    #[test]
16225    fn issue_490_hardbreak_continuation_still_works_for_text() {
16226        // Ensure regular hardBreak continuations still work after the fix.
16227        let md = "- first line\\\n  second line\n";
16228        let doc = markdown_to_adf(md).unwrap();
16229
16230        let item = &doc.content[0].content.as_ref().unwrap()[0];
16231        let children = item.content.as_ref().unwrap();
16232        assert_eq!(
16233            children.len(),
16234            1,
16235            "expected 1 child (paragraph) in listItem"
16236        );
16237        assert_eq!(children[0].node_type, "paragraph");
16238        let inlines = children[0].content.as_ref().unwrap();
16239        // Should contain: text("first line"), hardBreak, text("second line")
16240        assert_eq!(inlines.len(), 3);
16241        assert_eq!(inlines[0].node_type, "text");
16242        assert_eq!(inlines[1].node_type, "hardBreak");
16243        assert_eq!(inlines[2].node_type, "text");
16244    }
16245
16246    #[test]
16247    fn issue_490_external_media_after_hardbreak_roundtrip() {
16248        // External image (URL-based) after a paragraph with hardBreak.
16249        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
16250          {"type":"listItem","content":[
16251            {"type":"paragraph","content":[
16252              {"type":"text","text":"See image:"},
16253              {"type":"hardBreak"}
16254            ]},
16255            {"type":"mediaSingle","attrs":{"layout":"center"},
16256             "content":[{"type":"media","attrs":{
16257               "type":"external","url":"https://example.com/photo.png","alt":"photo"
16258             }}]}
16259          ]}
16260        ]}]}"#;
16261        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16262        let md = adf_to_markdown(&doc).unwrap();
16263        let rt = markdown_to_adf(&md).unwrap();
16264
16265        let item = &rt.content[0].content.as_ref().unwrap()[0];
16266        let children = item.content.as_ref().unwrap();
16267        assert_eq!(children.len(), 2);
16268        assert_eq!(children[0].node_type, "paragraph");
16269        assert_eq!(children[1].node_type, "mediaSingle");
16270        let media = &children[1].content.as_ref().unwrap()[0];
16271        let m_attrs = media.attrs.as_ref().unwrap();
16272        assert_eq!(m_attrs["url"], "https://example.com/photo.png");
16273    }
16274
16275    #[test]
16276    fn issue_490_multiple_hardbreaks_then_media_single() {
16277        // Paragraph with multiple hardBreaks, then mediaSingle.
16278        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
16279          {"type":"listItem","content":[
16280            {"type":"paragraph","content":[
16281              {"type":"text","text":"line one"},
16282              {"type":"hardBreak"},
16283              {"type":"text","text":"line two"},
16284              {"type":"hardBreak"}
16285            ]},
16286            {"type":"mediaSingle","attrs":{"layout":"center"},
16287             "content":[{"type":"media","attrs":{
16288               "type":"file","id":"multi-hb","collection":"col-m","width":320,"height":240
16289             }}]}
16290          ]}
16291        ]}]}"#;
16292        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16293        let md = adf_to_markdown(&doc).unwrap();
16294        let rt = markdown_to_adf(&md).unwrap();
16295
16296        let item = &rt.content[0].content.as_ref().unwrap()[0];
16297        let children = item.content.as_ref().unwrap();
16298        assert_eq!(children.len(), 2, "expected paragraph + mediaSingle");
16299        assert_eq!(children[0].node_type, "paragraph");
16300        assert_eq!(children[1].node_type, "mediaSingle");
16301        let media = &children[1].content.as_ref().unwrap()[0];
16302        assert_eq!(media.attrs.as_ref().unwrap()["id"], "multi-hb");
16303    }
16304
16305    // ── Issue #525: listItem localId dropped when content includes mediaSingle ──
16306
16307    #[test]
16308    fn issue_525_listitem_localid_with_mediasingle_roundtrip() {
16309        // Exact reproducer from issue #525: listItem with UUID localId whose
16310        // content includes mediaSingle + paragraph + nested bulletList.
16311        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","attrs":{"localId":"aabbccdd-1234-5678-abcd-000000000001"},"content":[{"type":"mediaSingle","attrs":{"layout":"center","width":100,"widthType":"pixel"},"content":[{"type":"media","attrs":{"id":"aabbccdd-1234-5678-abcd-000000000002","type":"file","collection":"test-collection","height":100,"width":100}}]},{"type":"paragraph","content":[{"type":"text","text":"some text"}]},{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"nested item"}]}]}]}]}]}]}"#;
16312        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16313        let md = adf_to_markdown(&doc).unwrap();
16314        let rt = markdown_to_adf(&md).unwrap();
16315
16316        let list = &rt.content[0];
16317        assert_eq!(list.node_type, "bulletList");
16318        let item = &list.content.as_ref().unwrap()[0];
16319        // The localId must be preserved on the listItem.
16320        let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
16321        assert_eq!(
16322            item_attrs["localId"], "aabbccdd-1234-5678-abcd-000000000001",
16323            "listItem localId must survive round-trip"
16324        );
16325        let children = item.content.as_ref().unwrap();
16326        assert_eq!(
16327            children.len(),
16328            3,
16329            "expected mediaSingle + paragraph + bulletList"
16330        );
16331        assert_eq!(children[0].node_type, "mediaSingle");
16332        assert_eq!(children[1].node_type, "paragraph");
16333        assert_eq!(children[2].node_type, "bulletList");
16334    }
16335
16336    #[test]
16337    fn issue_525_listitem_localid_with_mediasingle_only() {
16338        // Minimal case: listItem with localId whose sole child is mediaSingle.
16339        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
16340          {"type":"listItem","attrs":{"localId":"li-media-only"},"content":[
16341            {"type":"mediaSingle","attrs":{"layout":"center"},
16342             "content":[{"type":"media","attrs":{"type":"file","id":"m-001","collection":"c1","height":50,"width":100}}]}
16343          ]}
16344        ]}]}"#;
16345        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16346        let md = adf_to_markdown(&doc).unwrap();
16347        let rt = markdown_to_adf(&md).unwrap();
16348
16349        let item = &rt.content[0].content.as_ref().unwrap()[0];
16350        let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
16351        assert_eq!(
16352            item_attrs["localId"], "li-media-only",
16353            "listItem localId must survive when sole child is mediaSingle"
16354        );
16355        assert_eq!(item.content.as_ref().unwrap()[0].node_type, "mediaSingle");
16356    }
16357
16358    #[test]
16359    fn issue_525_listitem_localid_with_external_media() {
16360        // External image (URL-based) as first child with listItem localId.
16361        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
16362          {"type":"listItem","attrs":{"localId":"li-ext-media"},"content":[
16363            {"type":"mediaSingle","attrs":{"layout":"center"},
16364             "content":[{"type":"media","attrs":{"type":"external","url":"https://example.com/img.png","alt":"photo"}}]}
16365          ]}
16366        ]}]}"#;
16367        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16368        let md = adf_to_markdown(&doc).unwrap();
16369        let rt = markdown_to_adf(&md).unwrap();
16370
16371        let item = &rt.content[0].content.as_ref().unwrap()[0];
16372        let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
16373        assert_eq!(
16374            item_attrs["localId"], "li-ext-media",
16375            "listItem localId must survive with external mediaSingle"
16376        );
16377    }
16378
16379    #[test]
16380    fn issue_525_listitem_localid_with_mediasingle_in_ordered_list() {
16381        // Same bug in an ordered list.
16382        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
16383          {"type":"listItem","attrs":{"localId":"li-ord-media"},"content":[
16384            {"type":"mediaSingle","attrs":{"layout":"center","width":200,"widthType":"pixel"},
16385             "content":[{"type":"media","attrs":{"type":"file","id":"ord-m-001","collection":"col-ord","height":80,"width":160}}]},
16386            {"type":"paragraph","content":[{"type":"text","text":"ordered item text"}]}
16387          ]}
16388        ]}]}"#;
16389        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16390        let md = adf_to_markdown(&doc).unwrap();
16391        let rt = markdown_to_adf(&md).unwrap();
16392
16393        let item = &rt.content[0].content.as_ref().unwrap()[0];
16394        let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
16395        assert_eq!(
16396            item_attrs["localId"], "li-ord-media",
16397            "listItem localId must survive in ordered list with mediaSingle"
16398        );
16399        let children = item.content.as_ref().unwrap();
16400        assert_eq!(children[0].node_type, "mediaSingle");
16401        assert_eq!(children[1].node_type, "paragraph");
16402    }
16403
16404    #[test]
16405    fn issue_525_jfm_localid_on_mediasingle_line_parses_correctly() {
16406        // Verify JFM→ADF: trailing {localId=...} on a mediaSingle line
16407        // is assigned to the listItem, not the media node.
16408        let md = "- ![](){type=file id=test-525 collection=col height=100 width=200 mediaWidth=100 widthType=pixel} {localId=li-jfm-525}\n";
16409        let doc = markdown_to_adf(md).unwrap();
16410
16411        let item = &doc.content[0].content.as_ref().unwrap()[0];
16412        let item_attrs = item
16413            .attrs
16414            .as_ref()
16415            .expect("listItem attrs must be present from JFM");
16416        assert_eq!(item_attrs["localId"], "li-jfm-525");
16417        assert_eq!(item.content.as_ref().unwrap()[0].node_type, "mediaSingle");
16418    }
16419
16420    #[test]
16421    fn issue_525_encoding_emits_localid_on_mediasingle_line() {
16422        // Verify the ADF→JFM encoding: localId appears on the same line
16423        // as the mediaSingle image syntax.
16424        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
16425          {"type":"listItem","attrs":{"localId":"li-emit-check"},"content":[
16426            {"type":"mediaSingle","attrs":{"layout":"center"},
16427             "content":[{"type":"media","attrs":{"type":"file","id":"m-emit","collection":"c-emit","height":10,"width":20}}]}
16428          ]}
16429        ]}]}"#;
16430        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16431        let md = adf_to_markdown(&doc).unwrap();
16432        assert!(
16433            md.contains("{localId=li-emit-check}"),
16434            "expected localId in JFM output, got: {md}"
16435        );
16436        // The localId must be on the same line as the image
16437        for line in md.lines() {
16438            if line.contains("![") {
16439                assert!(
16440                    line.contains("localId=li-emit-check"),
16441                    "localId must be on the same line as the image: {line}"
16442                );
16443            }
16444        }
16445    }
16446
16447    // ── Placeholder node tests ────────────────────────────────────
16448
16449    #[test]
16450    fn adf_placeholder_to_markdown() {
16451        let doc = AdfDocument {
16452            version: 1,
16453            doc_type: "doc".to_string(),
16454            content: vec![AdfNode::paragraph(vec![AdfNode::placeholder(
16455                "Type something here",
16456            )])],
16457        };
16458        let md = adf_to_markdown(&doc).unwrap();
16459        assert!(
16460            md.contains(":placeholder[Type something here]"),
16461            "expected :placeholder directive, got: {md}"
16462        );
16463    }
16464
16465    #[test]
16466    fn markdown_placeholder_to_adf() {
16467        let doc = markdown_to_adf("Before :placeholder[Enter name] after").unwrap();
16468        let content = doc.content[0].content.as_ref().unwrap();
16469        assert_eq!(content[1].node_type, "placeholder");
16470        let attrs = content[1].attrs.as_ref().unwrap();
16471        assert_eq!(attrs["text"], "Enter name");
16472    }
16473
16474    #[test]
16475    fn placeholder_round_trip() {
16476        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"placeholder","attrs":{"text":"Type something here"}}]}]}"#;
16477        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16478        let md = adf_to_markdown(&doc).unwrap();
16479        let rt = markdown_to_adf(&md).unwrap();
16480        let content = rt.content[0].content.as_ref().unwrap();
16481        assert_eq!(content.len(), 1);
16482        assert_eq!(content[0].node_type, "placeholder");
16483        let attrs = content[0].attrs.as_ref().unwrap();
16484        assert_eq!(attrs["text"], "Type something here");
16485    }
16486
16487    #[test]
16488    fn placeholder_empty_text() {
16489        let doc = AdfDocument {
16490            version: 1,
16491            doc_type: "doc".to_string(),
16492            content: vec![AdfNode::paragraph(vec![AdfNode::placeholder("")])],
16493        };
16494        let md = adf_to_markdown(&doc).unwrap();
16495        assert!(
16496            md.contains(":placeholder[]"),
16497            "expected empty placeholder directive, got: {md}"
16498        );
16499        let rt = markdown_to_adf(&md).unwrap();
16500        let content = rt.content[0].content.as_ref().unwrap();
16501        assert_eq!(content[0].node_type, "placeholder");
16502        assert_eq!(content[0].attrs.as_ref().unwrap()["text"], "");
16503    }
16504
16505    #[test]
16506    fn placeholder_with_surrounding_text() {
16507        let md = "Click :placeholder[here] to continue\n";
16508        let doc = markdown_to_adf(md).unwrap();
16509        let content = doc.content[0].content.as_ref().unwrap();
16510        assert_eq!(content[0].text.as_deref(), Some("Click "));
16511        assert_eq!(content[1].node_type, "placeholder");
16512        assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "here");
16513        assert_eq!(content[2].text.as_deref(), Some(" to continue"));
16514    }
16515
16516    #[test]
16517    fn placeholder_missing_attrs() {
16518        // Placeholder node with no attrs should not panic
16519        let doc = AdfDocument {
16520            version: 1,
16521            doc_type: "doc".to_string(),
16522            content: vec![AdfNode::paragraph(vec![AdfNode {
16523                node_type: "placeholder".to_string(),
16524                attrs: None,
16525                content: None,
16526                text: None,
16527                marks: None,
16528                local_id: None,
16529                parameters: None,
16530            }])],
16531        };
16532        let md = adf_to_markdown(&doc).unwrap();
16533        // With no attrs, nothing is emitted for the placeholder
16534        assert!(!md.contains("placeholder"));
16535    }
16536
16537    // Issue #446: mention in table+list loses id and misplaces localId
16538    #[test]
16539    fn mention_in_table_bullet_list_preserves_id_and_local_id() {
16540        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colspan":1,"colwidth":[200],"rowspan":1},"content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"prefix text "},{"type":"mention","attrs":{"id":"aabbccdd11223344aabbccdd","localId":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","text":"@Alice Example"}},{"type":"text","text":" "}]}]}]}]}]}]}]}"#;
16541        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16542        let md = adf_to_markdown(&doc).unwrap();
16543        let rt = markdown_to_adf(&md).unwrap();
16544
16545        // Navigate: doc → table → tableRow → tableCell → bulletList → listItem → paragraph
16546        let cell = &rt.content[0].content.as_ref().unwrap()[0]
16547            .content
16548            .as_ref()
16549            .unwrap()[0];
16550        let list = &cell.content.as_ref().unwrap()[0];
16551        let list_item = &list.content.as_ref().unwrap()[0];
16552
16553        // listItem must NOT have a localId attribute
16554        assert!(
16555            list_item
16556                .attrs
16557                .as_ref()
16558                .and_then(|a| a.get("localId"))
16559                .is_none(),
16560            "localId should stay on the mention, not the listItem"
16561        );
16562
16563        let para = &list_item.content.as_ref().unwrap()[0];
16564        let inlines = para.content.as_ref().unwrap();
16565
16566        // Should have: text("prefix text "), mention, text(" ")
16567        assert_eq!(inlines.len(), 3, "expected 3 inline nodes, got {inlines:?}");
16568
16569        assert_eq!(inlines[0].node_type, "text");
16570        assert_eq!(inlines[0].text.as_deref(), Some("prefix text "));
16571
16572        assert_eq!(inlines[1].node_type, "mention");
16573        let mention_attrs = inlines[1].attrs.as_ref().unwrap();
16574        assert_eq!(
16575            mention_attrs["id"], "aabbccdd11223344aabbccdd",
16576            "mention id must be preserved"
16577        );
16578        assert_eq!(
16579            mention_attrs["localId"], "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
16580            "mention localId must be preserved"
16581        );
16582        assert_eq!(mention_attrs["text"], "@Alice Example");
16583
16584        assert_eq!(inlines[2].node_type, "text");
16585        assert_eq!(inlines[2].text.as_deref(), Some(" "));
16586    }
16587
16588    #[test]
16589    fn mention_in_bullet_list_preserves_id_and_local_id() {
16590        // Same bug outside of a table context
16591        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"mention","attrs":{"id":"user123","localId":"11111111-2222-3333-4444-555555555555","text":"@Bob"}},{"type":"text","text":" "}]}]}]}]}"#;
16592        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16593        let md = adf_to_markdown(&doc).unwrap();
16594        let rt = markdown_to_adf(&md).unwrap();
16595
16596        let list_item = &rt.content[0].content.as_ref().unwrap()[0];
16597        assert!(
16598            list_item
16599                .attrs
16600                .as_ref()
16601                .and_then(|a| a.get("localId"))
16602                .is_none(),
16603            "localId should stay on the mention, not the listItem"
16604        );
16605
16606        let para = &list_item.content.as_ref().unwrap()[0];
16607        let inlines = para.content.as_ref().unwrap();
16608        assert_eq!(inlines[0].node_type, "mention");
16609        let mention_attrs = inlines[0].attrs.as_ref().unwrap();
16610        assert_eq!(mention_attrs["id"], "user123");
16611        assert_eq!(
16612            mention_attrs["localId"],
16613            "11111111-2222-3333-4444-555555555555"
16614        );
16615    }
16616
16617    #[test]
16618    fn mention_in_ordered_list_preserves_id_and_local_id() {
16619        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"see "},{"type":"mention","attrs":{"id":"xyz","localId":"aaaa-bbbb","text":"@Carol"}}]}]}]}]}"#;
16620        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16621        let md = adf_to_markdown(&doc).unwrap();
16622        let rt = markdown_to_adf(&md).unwrap();
16623
16624        let list_item = &rt.content[0].content.as_ref().unwrap()[0];
16625        assert!(
16626            list_item
16627                .attrs
16628                .as_ref()
16629                .and_then(|a| a.get("localId"))
16630                .is_none(),
16631            "localId should stay on the mention, not the listItem"
16632        );
16633
16634        let para = &list_item.content.as_ref().unwrap()[0];
16635        let inlines = para.content.as_ref().unwrap();
16636        assert_eq!(inlines[1].node_type, "mention");
16637        let mention_attrs = inlines[1].attrs.as_ref().unwrap();
16638        assert_eq!(mention_attrs["id"], "xyz");
16639        assert_eq!(mention_attrs["localId"], "aaaa-bbbb");
16640    }
16641
16642    #[test]
16643    fn list_item_own_local_id_with_mention_both_preserved() {
16644        // When a listItem has its own localId AND contains a mention with localId,
16645        // both should be preserved independently.
16646        let md = "- hello :mention[@Eve]{id=e1 localId=mention-lid} {localId=item-lid}\n";
16647        let doc = markdown_to_adf(md).unwrap();
16648        let list_item = &doc.content[0].content.as_ref().unwrap()[0];
16649
16650        // listItem should have its own localId
16651        let item_attrs = list_item.attrs.as_ref().unwrap();
16652        assert_eq!(item_attrs["localId"], "item-lid");
16653
16654        // mention should have its own localId
16655        let para = &list_item.content.as_ref().unwrap()[0];
16656        let inlines = para.content.as_ref().unwrap();
16657        let mention = inlines.iter().find(|n| n.node_type == "mention").unwrap();
16658        let mention_attrs = mention.attrs.as_ref().unwrap();
16659        assert_eq!(mention_attrs["id"], "e1");
16660        assert_eq!(mention_attrs["localId"], "mention-lid");
16661    }
16662
16663    #[test]
16664    fn extract_trailing_local_id_ignores_directive_attrs() {
16665        // Directly test the helper: a line ending with a directive's {…}
16666        // should NOT be treated as a trailing localId.
16667        let line = "text :mention[@X]{id=abc localId=uuid}";
16668        let (text, lid, plid) = extract_trailing_local_id(line);
16669        assert_eq!(text, line, "text should be unchanged");
16670        assert!(
16671            lid.is_none(),
16672            "should not extract localId from directive attrs"
16673        );
16674        assert!(plid.is_none());
16675    }
16676
16677    #[test]
16678    fn extract_trailing_local_id_matches_standalone_block() {
16679        // A standalone trailing {localId=…} separated by whitespace should still work.
16680        let line = "some text {localId=abc-123}";
16681        let (text, lid, plid) = extract_trailing_local_id(line);
16682        assert_eq!(text, "some text");
16683        assert_eq!(lid.as_deref(), Some("abc-123"));
16684        assert!(plid.is_none());
16685    }
16686
16687    // --- Issue #454: literal newline in text node inside listItem paragraph ---
16688
16689    #[test]
16690    fn newline_in_text_node_roundtrips_in_bullet_list() {
16691        // A text node containing a literal \n inside a bullet list item
16692        // must round-trip as a single text node with the embedded newline
16693        // preserved, not split into multiple paragraphs or hardBreak nodes.
16694        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Run these commands:"},{"type":"hardBreak"},{"type":"text","text":"first command\nsecond command"}]}]}]}]}"#;
16695        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16696        let md = adf_to_markdown(&doc).unwrap();
16697        let rt = markdown_to_adf(&md).unwrap();
16698
16699        // Should still be a single bulletList with one listItem
16700        assert_eq!(rt.content.len(), 1);
16701        let list = &rt.content[0];
16702        assert_eq!(list.node_type, "bulletList");
16703        let items = list.content.as_ref().unwrap();
16704        assert_eq!(items.len(), 1);
16705
16706        // The listItem should have exactly one paragraph child
16707        let item_content = items[0].content.as_ref().unwrap();
16708        assert_eq!(
16709            item_content.len(),
16710            1,
16711            "listItem should have exactly one paragraph"
16712        );
16713        assert_eq!(item_content[0].node_type, "paragraph");
16714
16715        // The embedded newline must survive as a single text node
16716        let inlines = item_content[0].content.as_ref().unwrap();
16717        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
16718        assert_eq!(
16719            types,
16720            vec!["text", "hardBreak", "text"],
16721            "embedded newline should stay in a single text node, not produce extra hardBreaks"
16722        );
16723        assert_eq!(
16724            inlines[2].text.as_deref(),
16725            Some("first command\nsecond command")
16726        );
16727    }
16728
16729    #[test]
16730    fn newline_in_text_node_roundtrips_in_ordered_list() {
16731        // Same as above but in an ordered list.
16732        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"first\nsecond"}]}]}]}]}"#;
16733        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16734        let md = adf_to_markdown(&doc).unwrap();
16735        let rt = markdown_to_adf(&md).unwrap();
16736
16737        let list = &rt.content[0];
16738        assert_eq!(list.node_type, "orderedList");
16739        let items = list.content.as_ref().unwrap();
16740        assert_eq!(items.len(), 1);
16741
16742        let item_content = items[0].content.as_ref().unwrap();
16743        assert_eq!(item_content.len(), 1);
16744        assert_eq!(item_content[0].node_type, "paragraph");
16745
16746        let inlines = item_content[0].content.as_ref().unwrap();
16747        assert_eq!(inlines.len(), 1);
16748        assert_eq!(inlines[0].node_type, "text");
16749        assert_eq!(inlines[0].text.as_deref(), Some("first\nsecond"));
16750    }
16751
16752    #[test]
16753    fn newline_in_text_node_roundtrips_in_paragraph() {
16754        // A text node with \n in a top-level paragraph should render as
16755        // escaped \n and round-trip back to a single text node.
16756        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello\nworld"}]}]}"#;
16757        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16758        let md = adf_to_markdown(&doc).unwrap();
16759        assert!(
16760            md.contains("hello\\nworld"),
16761            "newline in text node should render as escaped \\n: {md:?}"
16762        );
16763
16764        let rt = markdown_to_adf(&md).unwrap();
16765        let inlines = rt.content[0].content.as_ref().unwrap();
16766        assert_eq!(inlines.len(), 1);
16767        assert_eq!(inlines[0].text.as_deref(), Some("hello\nworld"));
16768    }
16769
16770    #[test]
16771    fn multiple_newlines_in_text_node_roundtrip() {
16772        // Multiple \n characters should each round-trip within the same text node.
16773        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a\nb\nc"}]}]}]}]}"#;
16774        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16775        let md = adf_to_markdown(&doc).unwrap();
16776        let rt = markdown_to_adf(&md).unwrap();
16777
16778        let item_content = rt.content[0].content.as_ref().unwrap()[0]
16779            .content
16780            .as_ref()
16781            .unwrap();
16782        assert_eq!(item_content.len(), 1);
16783
16784        let inlines = item_content[0].content.as_ref().unwrap();
16785        assert_eq!(inlines.len(), 1);
16786        assert_eq!(inlines[0].text.as_deref(), Some("a\nb\nc"));
16787    }
16788
16789    #[test]
16790    fn newline_in_marked_text_node_roundtrips() {
16791        // A bold text node with \n should round-trip preserving both
16792        // the marks and the embedded newline.
16793        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"bold\ntext","marks":[{"type":"strong"}]}]}]}"#;
16794        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16795        let md = adf_to_markdown(&doc).unwrap();
16796        assert!(
16797            md.contains("**bold\\ntext**"),
16798            "bold text with embedded newline should stay in one marked run: {md:?}"
16799        );
16800
16801        let rt = markdown_to_adf(&md).unwrap();
16802        let inlines = rt.content[0].content.as_ref().unwrap();
16803        assert_eq!(inlines.len(), 1);
16804        assert_eq!(inlines[0].text.as_deref(), Some("bold\ntext"));
16805        assert!(inlines[0]
16806            .marks
16807            .as_ref()
16808            .unwrap()
16809            .iter()
16810            .any(|m| m.mark_type == "strong"));
16811    }
16812
16813    #[test]
16814    fn trailing_newline_in_text_node_roundtrips() {
16815        // A text node ending with \n should round-trip preserving the
16816        // trailing newline.
16817        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"trailing\n"}]}]}"#;
16818        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16819        let md = adf_to_markdown(&doc).unwrap();
16820        assert!(
16821            md.contains("trailing\\n"),
16822            "trailing newline should be escaped: {md:?}"
16823        );
16824
16825        let rt = markdown_to_adf(&md).unwrap();
16826        let inlines = rt.content[0].content.as_ref().unwrap();
16827        assert_eq!(inlines.len(), 1);
16828        assert_eq!(inlines[0].text.as_deref(), Some("trailing\n"));
16829    }
16830
16831    #[test]
16832    fn hardbreak_and_embedded_newline_are_distinct() {
16833        // A hardBreak node and an embedded \n in a text node must not be
16834        // conflated — each must round-trip to its original form.
16835        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"before"},{"type":"hardBreak"},{"type":"text","text":"mid\ndle"},{"type":"hardBreak"},{"type":"text","text":"after"}]}]}"#;
16836        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16837        let md = adf_to_markdown(&doc).unwrap();
16838        let rt = markdown_to_adf(&md).unwrap();
16839
16840        let inlines = rt.content[0].content.as_ref().unwrap();
16841        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
16842        assert_eq!(
16843            types,
16844            vec!["text", "hardBreak", "text", "hardBreak", "text"]
16845        );
16846        assert_eq!(inlines[0].text.as_deref(), Some("before"));
16847        assert_eq!(inlines[2].text.as_deref(), Some("mid\ndle"));
16848        assert_eq!(inlines[4].text.as_deref(), Some("after"));
16849    }
16850
16851    // ---- Issue #472 tests ----
16852
16853    #[test]
16854    fn issue_472_bullet_list_trailing_hardbreak_roundtrips() {
16855        // Issue #472: trailing hardBreak at end of listItem paragraph must
16856        // not split the parent bulletList on round-trip.
16857        let adf_json = r#"{"version":1,"type":"doc","content":[
16858          {"type":"bulletList","content":[
16859            {"type":"listItem","content":[
16860              {"type":"paragraph","content":[
16861                {"type":"text","text":"First item"},
16862                {"type":"hardBreak"}
16863              ]}
16864            ]},
16865            {"type":"listItem","content":[
16866              {"type":"paragraph","content":[
16867                {"type":"text","text":"Second item"}
16868              ]}
16869            ]}
16870          ]}
16871        ]}"#;
16872        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16873        let md = adf_to_markdown(&doc).unwrap();
16874        let rt = markdown_to_adf(&md).unwrap();
16875
16876        // Must remain a single bulletList
16877        assert_eq!(
16878            rt.content.len(),
16879            1,
16880            "Should be 1 block (bulletList), got {}",
16881            rt.content.len()
16882        );
16883        assert_eq!(rt.content[0].node_type, "bulletList");
16884        let items = rt.content[0].content.as_ref().unwrap();
16885        assert_eq!(
16886            items.len(),
16887            2,
16888            "Should have 2 listItems, got {}",
16889            items.len()
16890        );
16891
16892        // First item: text + hardBreak (trailing)
16893        let p1 = items[0].content.as_ref().unwrap()[0]
16894            .content
16895            .as_ref()
16896            .unwrap();
16897        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
16898        assert_eq!(types1, vec!["text", "hardBreak"]);
16899        assert_eq!(p1[0].text.as_deref(), Some("First item"));
16900
16901        // Second item: text only
16902        let p2 = items[1].content.as_ref().unwrap()[0]
16903            .content
16904            .as_ref()
16905            .unwrap();
16906        assert_eq!(p2[0].text.as_deref(), Some("Second item"));
16907    }
16908
16909    #[test]
16910    fn issue_472_ordered_list_trailing_hardbreak_roundtrips() {
16911        // Ordered list variant of issue #472.
16912        let adf_json = r#"{"version":1,"type":"doc","content":[
16913          {"type":"orderedList","attrs":{"order":1},"content":[
16914            {"type":"listItem","content":[
16915              {"type":"paragraph","content":[
16916                {"type":"text","text":"Alpha"},
16917                {"type":"hardBreak"}
16918              ]}
16919            ]},
16920            {"type":"listItem","content":[
16921              {"type":"paragraph","content":[
16922                {"type":"text","text":"Beta"}
16923              ]}
16924            ]}
16925          ]}
16926        ]}"#;
16927        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16928        let md = adf_to_markdown(&doc).unwrap();
16929        let rt = markdown_to_adf(&md).unwrap();
16930
16931        assert_eq!(rt.content.len(), 1);
16932        assert_eq!(rt.content[0].node_type, "orderedList");
16933        let items = rt.content[0].content.as_ref().unwrap();
16934        assert_eq!(items.len(), 2);
16935
16936        let p1 = items[0].content.as_ref().unwrap()[0]
16937            .content
16938            .as_ref()
16939            .unwrap();
16940        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
16941        assert_eq!(types1, vec!["text", "hardBreak"]);
16942        assert_eq!(p1[0].text.as_deref(), Some("Alpha"));
16943    }
16944
16945    #[test]
16946    fn issue_472_trailing_hardbreak_jfm_no_blank_line() {
16947        // The rendered JFM must not contain a blank line after the
16948        // trailing hardBreak — that would split the list.
16949        let adf_json = r#"{"version":1,"type":"doc","content":[
16950          {"type":"bulletList","content":[
16951            {"type":"listItem","content":[
16952              {"type":"paragraph","content":[
16953                {"type":"text","text":"Hello"},
16954                {"type":"hardBreak"}
16955              ]}
16956            ]},
16957            {"type":"listItem","content":[
16958              {"type":"paragraph","content":[
16959                {"type":"text","text":"World"}
16960              ]}
16961            ]}
16962          ]}
16963        ]}"#;
16964        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16965        let md = adf_to_markdown(&doc).unwrap();
16966
16967        // Should produce "- Hello\\n- World\n" (no blank line between items).
16968        assert_eq!(md, "- Hello\\\n- World\n");
16969    }
16970
16971    #[test]
16972    fn issue_472_multiple_trailing_hardbreaks_roundtrip() {
16973        // Multiple trailing hardBreaks at the end of a listItem paragraph.
16974        let adf_json = r#"{"version":1,"type":"doc","content":[
16975          {"type":"bulletList","content":[
16976            {"type":"listItem","content":[
16977              {"type":"paragraph","content":[
16978                {"type":"text","text":"Item"},
16979                {"type":"hardBreak"},
16980                {"type":"hardBreak"}
16981              ]}
16982            ]},
16983            {"type":"listItem","content":[
16984              {"type":"paragraph","content":[
16985                {"type":"text","text":"Next"}
16986              ]}
16987            ]}
16988          ]}
16989        ]}"#;
16990        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16991        let md = adf_to_markdown(&doc).unwrap();
16992        let rt = markdown_to_adf(&md).unwrap();
16993
16994        // Must remain a single bulletList
16995        assert_eq!(rt.content.len(), 1);
16996        assert_eq!(rt.content[0].node_type, "bulletList");
16997        let items = rt.content[0].content.as_ref().unwrap();
16998        assert_eq!(items.len(), 2);
16999
17000        // First item should preserve both hardBreaks
17001        let p1 = items[0].content.as_ref().unwrap()[0]
17002            .content
17003            .as_ref()
17004            .unwrap();
17005        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
17006        assert_eq!(types1, vec!["text", "hardBreak", "hardBreak"]);
17007    }
17008
17009    #[test]
17010    fn issue_472_hardbreak_mid_and_trailing_roundtrip() {
17011        // A hardBreak in the middle AND at the end of a listItem paragraph.
17012        let adf_json = r#"{"version":1,"type":"doc","content":[
17013          {"type":"bulletList","content":[
17014            {"type":"listItem","content":[
17015              {"type":"paragraph","content":[
17016                {"type":"text","text":"Line one"},
17017                {"type":"hardBreak"},
17018                {"type":"text","text":"Line two"},
17019                {"type":"hardBreak"}
17020              ]}
17021            ]},
17022            {"type":"listItem","content":[
17023              {"type":"paragraph","content":[
17024                {"type":"text","text":"Other item"}
17025              ]}
17026            ]}
17027          ]}
17028        ]}"#;
17029        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17030        let md = adf_to_markdown(&doc).unwrap();
17031        let rt = markdown_to_adf(&md).unwrap();
17032
17033        assert_eq!(rt.content.len(), 1);
17034        assert_eq!(rt.content[0].node_type, "bulletList");
17035        let items = rt.content[0].content.as_ref().unwrap();
17036        assert_eq!(items.len(), 2);
17037
17038        let p1 = items[0].content.as_ref().unwrap()[0]
17039            .content
17040            .as_ref()
17041            .unwrap();
17042        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
17043        assert_eq!(types1, vec!["text", "hardBreak", "text", "hardBreak"]);
17044        assert_eq!(p1[0].text.as_deref(), Some("Line one"));
17045        assert_eq!(p1[2].text.as_deref(), Some("Line two"));
17046    }
17047
17048    #[test]
17049    fn issue_472_only_hardbreak_in_listitem_paragraph() {
17050        // Edge case: paragraph contains only a hardBreak, no text.
17051        let adf_json = r#"{"version":1,"type":"doc","content":[
17052          {"type":"bulletList","content":[
17053            {"type":"listItem","content":[
17054              {"type":"paragraph","content":[
17055                {"type":"hardBreak"}
17056              ]}
17057            ]},
17058            {"type":"listItem","content":[
17059              {"type":"paragraph","content":[
17060                {"type":"text","text":"After"}
17061              ]}
17062            ]}
17063          ]}
17064        ]}"#;
17065        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17066        let md = adf_to_markdown(&doc).unwrap();
17067        let rt = markdown_to_adf(&md).unwrap();
17068
17069        // Must remain a single bulletList with 2 items
17070        assert_eq!(rt.content.len(), 1);
17071        assert_eq!(rt.content[0].node_type, "bulletList");
17072        let items = rt.content[0].content.as_ref().unwrap();
17073        assert_eq!(items.len(), 2);
17074    }
17075
17076    #[test]
17077    fn issue_472_three_items_middle_has_trailing_hardbreak() {
17078        // Three-item list where only the middle item has a trailing hardBreak.
17079        let adf_json = r#"{"version":1,"type":"doc","content":[
17080          {"type":"bulletList","content":[
17081            {"type":"listItem","content":[
17082              {"type":"paragraph","content":[
17083                {"type":"text","text":"First"}
17084              ]}
17085            ]},
17086            {"type":"listItem","content":[
17087              {"type":"paragraph","content":[
17088                {"type":"text","text":"Second"},
17089                {"type":"hardBreak"}
17090              ]}
17091            ]},
17092            {"type":"listItem","content":[
17093              {"type":"paragraph","content":[
17094                {"type":"text","text":"Third"}
17095              ]}
17096            ]}
17097          ]}
17098        ]}"#;
17099        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17100        let md = adf_to_markdown(&doc).unwrap();
17101        let rt = markdown_to_adf(&md).unwrap();
17102
17103        assert_eq!(rt.content.len(), 1);
17104        assert_eq!(rt.content[0].node_type, "bulletList");
17105        let items = rt.content[0].content.as_ref().unwrap();
17106        assert_eq!(items.len(), 3);
17107        assert_eq!(
17108            items[0].content.as_ref().unwrap()[0]
17109                .content
17110                .as_ref()
17111                .unwrap()[0]
17112                .text
17113                .as_deref(),
17114            Some("First")
17115        );
17116        assert_eq!(
17117            items[2].content.as_ref().unwrap()[0]
17118                .content
17119                .as_ref()
17120                .unwrap()[0]
17121                .text
17122                .as_deref(),
17123            Some("Third")
17124        );
17125    }
17126
17127    // ── Issue #494: trailing space-only text node after hardBreak ────
17128
17129    #[test]
17130    fn issue_494_space_after_hardbreak_roundtrip() {
17131        // The original reproducer from issue #494: a single space text
17132        // node following a hardBreak is silently dropped on round-trip.
17133        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
17134          {"type":"text","text":"Some text"},
17135          {"type":"hardBreak"},
17136          {"type":"text","text":" "}
17137        ]}]}"#;
17138        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17139        let md = adf_to_markdown(&doc).unwrap();
17140        let rt = markdown_to_adf(&md).unwrap();
17141        let inlines = rt.content[0].content.as_ref().unwrap();
17142        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17143        assert_eq!(
17144            types,
17145            vec!["text", "hardBreak", "text"],
17146            "space-only text node after hardBreak should survive round-trip"
17147        );
17148        assert_eq!(inlines[2].text.as_deref(), Some(" "));
17149    }
17150
17151    #[test]
17152    fn issue_494_multiple_spaces_after_hardbreak_roundtrip() {
17153        // Multiple spaces after hardBreak should also survive.
17154        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
17155          {"type":"text","text":"Hello"},
17156          {"type":"hardBreak"},
17157          {"type":"text","text":"   "}
17158        ]}]}"#;
17159        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17160        let md = adf_to_markdown(&doc).unwrap();
17161        let rt = markdown_to_adf(&md).unwrap();
17162        let inlines = rt.content[0].content.as_ref().unwrap();
17163        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17164        assert_eq!(
17165            types,
17166            vec!["text", "hardBreak", "text"],
17167            "multi-space text node after hardBreak should survive round-trip"
17168        );
17169        assert_eq!(inlines[2].text.as_deref(), Some("   "));
17170    }
17171
17172    #[test]
17173    fn issue_494_space_then_text_after_hardbreak_roundtrip() {
17174        // Space followed by real text after hardBreak — the space should
17175        // be preserved as part of the text node.
17176        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
17177          {"type":"text","text":"Before"},
17178          {"type":"hardBreak"},
17179          {"type":"text","text":" After"}
17180        ]}]}"#;
17181        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17182        let md = adf_to_markdown(&doc).unwrap();
17183        let rt = markdown_to_adf(&md).unwrap();
17184        let inlines = rt.content[0].content.as_ref().unwrap();
17185        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17186        assert_eq!(types, vec!["text", "hardBreak", "text"]);
17187        assert_eq!(inlines[2].text.as_deref(), Some(" After"));
17188    }
17189
17190    #[test]
17191    fn issue_494_hardbreak_then_space_then_hardbreak_roundtrip() {
17192        // Space sandwiched between two hardBreaks.
17193        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
17194          {"type":"text","text":"A"},
17195          {"type":"hardBreak"},
17196          {"type":"text","text":" "},
17197          {"type":"hardBreak"},
17198          {"type":"text","text":"B"}
17199        ]}]}"#;
17200        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17201        let md = adf_to_markdown(&doc).unwrap();
17202        let rt = markdown_to_adf(&md).unwrap();
17203        let inlines = rt.content[0].content.as_ref().unwrap();
17204        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17205        assert_eq!(
17206            types,
17207            vec!["text", "hardBreak", "text", "hardBreak", "text"],
17208            "space between two hardBreaks should survive round-trip"
17209        );
17210        assert_eq!(inlines[2].text.as_deref(), Some(" "));
17211        assert_eq!(inlines[4].text.as_deref(), Some("B"));
17212    }
17213
17214    #[test]
17215    fn issue_494_trailing_space_hardbreak_style_not_confused() {
17216        // A plain paragraph break (blank line) should still work after
17217        // a line that does NOT end with a hardBreak marker.
17218        let md = "first paragraph\n\nsecond paragraph\n";
17219        let doc = markdown_to_adf(md).unwrap();
17220        assert_eq!(
17221            doc.content.len(),
17222            2,
17223            "blank line should still separate paragraphs"
17224        );
17225    }
17226
17227    #[test]
17228    fn issue_494_space_after_trailing_space_hardbreak_roundtrip() {
17229        // Same bug but with trailing-space style hardBreak (two spaces
17230        // before newline) instead of backslash style.
17231        let md = "line one  \n   \n";
17232        // The above is: "line one" + trailing-space hardBreak + continuation
17233        // line "   " (2-space indent + 1 space content).  The space-only
17234        // continuation should not be treated as a blank paragraph break.
17235        let doc = markdown_to_adf(md).unwrap();
17236        let inlines = doc.content[0].content.as_ref().unwrap();
17237        let has_text_after_break = inlines.iter().any(|n| {
17238            n.node_type == "text"
17239                && n.text
17240                    .as_deref()
17241                    .is_some_and(|t| t.trim().is_empty() && !t.is_empty())
17242        });
17243        assert!(
17244            has_text_after_break || inlines.len() >= 2,
17245            "space-only line after trailing-space hardBreak should be preserved"
17246        );
17247    }
17248
17249    #[test]
17250    fn issue_494_space_after_hardbreak_in_list_item_roundtrip() {
17251        // Exercises the same bug inside a list item context.
17252        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
17253          {"type":"listItem","content":[{"type":"paragraph","content":[
17254            {"type":"text","text":"item"},
17255            {"type":"hardBreak"},
17256            {"type":"text","text":" "}
17257          ]}]}
17258        ]}]}"#;
17259        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17260        let md = adf_to_markdown(&doc).unwrap();
17261        let rt = markdown_to_adf(&md).unwrap();
17262        let list = &rt.content[0];
17263        let item = &list.content.as_ref().unwrap()[0];
17264        let para = &item.content.as_ref().unwrap()[0];
17265        let inlines = para.content.as_ref().unwrap();
17266        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17267        assert_eq!(
17268            types,
17269            vec!["text", "hardBreak", "text"],
17270            "space after hardBreak in list item should survive round-trip"
17271        );
17272        assert_eq!(inlines[2].text.as_deref(), Some(" "));
17273    }
17274
17275    // ── Issue #510: trailing spaces in text node should not become hardBreak ──
17276
17277    #[test]
17278    fn issue_510_trailing_double_space_paragraph_roundtrip() {
17279        // Two trailing spaces in a text node must survive round-trip without
17280        // being converted to a hardBreak or merging the next paragraph.
17281        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"first paragraph with trailing spaces  "}]},{"type":"paragraph","content":[{"type":"text","text":"second paragraph"}]}]}"#;
17282        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17283        let md = adf_to_markdown(&doc).unwrap();
17284        let rt = markdown_to_adf(&md).unwrap();
17285
17286        // Must produce two separate paragraphs
17287        assert_eq!(
17288            rt.content.len(),
17289            2,
17290            "should produce two paragraphs, got: {}",
17291            rt.content.len()
17292        );
17293        assert_eq!(rt.content[0].node_type, "paragraph");
17294        assert_eq!(rt.content[1].node_type, "paragraph");
17295
17296        // First paragraph text preserves trailing spaces
17297        let p1 = rt.content[0].content.as_ref().unwrap();
17298        assert_eq!(
17299            p1[0].text.as_deref(),
17300            Some("first paragraph with trailing spaces  "),
17301            "trailing spaces should be preserved in first paragraph"
17302        );
17303
17304        // Second paragraph is intact
17305        let p2 = rt.content[1].content.as_ref().unwrap();
17306        assert_eq!(p2[0].text.as_deref(), Some("second paragraph"));
17307
17308        // No hardBreak nodes should exist
17309        let all_types: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
17310        assert!(
17311            !all_types.contains(&"hardBreak"),
17312            "trailing spaces should not produce hardBreak, got: {all_types:?}"
17313        );
17314    }
17315
17316    #[test]
17317    fn issue_510_trailing_triple_space_roundtrip() {
17318        // Three trailing spaces also must not become a hardBreak.
17319        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"text   "}]},{"type":"paragraph","content":[{"type":"text","text":"next"}]}]}"#;
17320        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17321        let md = adf_to_markdown(&doc).unwrap();
17322        let rt = markdown_to_adf(&md).unwrap();
17323
17324        assert_eq!(rt.content.len(), 2, "should still be two paragraphs");
17325        let p1 = rt.content[0].content.as_ref().unwrap();
17326        assert_eq!(
17327            p1[0].text.as_deref(),
17328            Some("text   "),
17329            "three trailing spaces should be preserved"
17330        );
17331    }
17332
17333    #[test]
17334    fn issue_510_trailing_spaces_with_backslash_roundtrip() {
17335        // Text ending with backslash + trailing spaces: both must survive.
17336        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"end\\  "}]}]}"#;
17337        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17338        let md = adf_to_markdown(&doc).unwrap();
17339        let rt = markdown_to_adf(&md).unwrap();
17340        let p = rt.content[0].content.as_ref().unwrap();
17341        assert_eq!(
17342            p[0].text.as_deref(),
17343            Some("end\\  "),
17344            "backslash + trailing spaces should both survive"
17345        );
17346    }
17347
17348    #[test]
17349    fn issue_510_jfm_contains_escaped_trailing_space() {
17350        // Verify the serializer actually emits the backslash-space escape.
17351        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello  "}]}]}"#;
17352        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17353        let md = adf_to_markdown(&doc).unwrap();
17354        assert!(
17355            md.contains(r"\ "),
17356            "JFM should contain backslash-space escape for trailing spaces, got: {md:?}"
17357        );
17358        // Must NOT end with two plain spaces before newline
17359        for line in md.lines() {
17360            assert!(
17361                !line.ends_with("  "),
17362                "no JFM line should end with two plain spaces, got: {line:?}"
17363            );
17364        }
17365    }
17366
17367    #[test]
17368    fn issue_510_single_trailing_space_not_escaped() {
17369        // A single trailing space should NOT be escaped (not a hardBreak trigger).
17370        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"word "}]}]}"#;
17371        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17372        let md = adf_to_markdown(&doc).unwrap();
17373        assert!(
17374            !md.contains('\\'),
17375            "single trailing space should not be escaped, got: {md:?}"
17376        );
17377        let rt = markdown_to_adf(&md).unwrap();
17378        let p = rt.content[0].content.as_ref().unwrap();
17379        assert_eq!(p[0].text.as_deref(), Some("word "));
17380    }
17381
17382    #[test]
17383    fn issue_510_trailing_spaces_in_heading_roundtrip() {
17384        // Trailing double-spaces in a heading text node should also survive.
17385        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"heading  "}]}]}"#;
17386        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17387        let md = adf_to_markdown(&doc).unwrap();
17388        let rt = markdown_to_adf(&md).unwrap();
17389        let h = rt.content[0].content.as_ref().unwrap();
17390        assert_eq!(
17391            h[0].text.as_deref(),
17392            Some("heading  "),
17393            "trailing spaces in heading should be preserved"
17394        );
17395    }
17396
17397    #[test]
17398    fn issue_510_trailing_spaces_in_list_item_roundtrip() {
17399        // Trailing double-spaces in a bullet list item text node.
17400        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"item  "}]}]}]}]}"#;
17401        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17402        let md = adf_to_markdown(&doc).unwrap();
17403        let rt = markdown_to_adf(&md).unwrap();
17404        let list = &rt.content[0];
17405        let item = &list.content.as_ref().unwrap()[0];
17406        let para = &item.content.as_ref().unwrap()[0];
17407        let inlines = para.content.as_ref().unwrap();
17408        assert_eq!(
17409            inlines[0].text.as_deref(),
17410            Some("item  "),
17411            "trailing spaces in list item should be preserved"
17412        );
17413    }
17414
17415    #[test]
17416    fn issue_510_trailing_spaces_with_bold_mark_roundtrip() {
17417        // Trailing spaces in a bold-marked text node: the closing **
17418        // comes after the spaces, so the line doesn't end with spaces.
17419        // But the escape should still be applied (and be harmless).
17420        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"bold  ","marks":[{"type":"strong"}]}]}]}"#;
17421        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17422        let md = adf_to_markdown(&doc).unwrap();
17423        let rt = markdown_to_adf(&md).unwrap();
17424        let p = rt.content[0].content.as_ref().unwrap();
17425        assert_eq!(
17426            p[0].text.as_deref(),
17427            Some("bold  "),
17428            "trailing spaces in bold text should be preserved"
17429        );
17430    }
17431
17432    #[test]
17433    fn issue_510_hardbreak_between_paragraphs_still_works() {
17434        // Actual hardBreak nodes must still round-trip correctly.
17435        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"line one"},{"type":"hardBreak"},{"type":"text","text":"line two"}]}]}"#;
17436        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17437        let md = adf_to_markdown(&doc).unwrap();
17438        let rt = markdown_to_adf(&md).unwrap();
17439        let inlines = rt.content[0].content.as_ref().unwrap();
17440        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17441        assert_eq!(
17442            types,
17443            vec!["text", "hardBreak", "text"],
17444            "explicit hardBreak should still round-trip"
17445        );
17446    }
17447
17448    #[test]
17449    fn issue_510_all_spaces_text_node_roundtrip() {
17450        // A text node that is entirely spaces (2+) should survive.
17451        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"  "}]}]}"#;
17452        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17453        let md = adf_to_markdown(&doc).unwrap();
17454        let rt = markdown_to_adf(&md).unwrap();
17455        let p = rt.content[0].content.as_ref().unwrap();
17456        assert_eq!(
17457            p[0].text.as_deref(),
17458            Some("  "),
17459            "space-only text node should survive round-trip"
17460        );
17461    }
17462
17463    // ── Issue #522: listItem multi-paragraph merge ──────────────────────
17464
17465    #[test]
17466    fn issue_522_listitem_hardbreak_then_two_paragraphs_roundtrips() {
17467        // The exact reproducer from issue #522: first paragraph has
17468        // hardBreak nodes, followed by two sibling paragraphs.
17469        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"preamble"},{"type":"hardBreak"},{"type":"text","text":"\u00a0"},{"type":"hardBreak"},{"type":"text","text":"line with "},{"marks":[{"type":"code"}],"text":"code","type":"text"},{"type":"text","text":". "}]},{"type":"paragraph","content":[{"type":"text","text":"second paragraph"}]},{"type":"paragraph","content":[{"type":"text","text":"third paragraph"}]}]}]}]}"#;
17470        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17471        let md = adf_to_markdown(&doc).unwrap();
17472        let rt = markdown_to_adf(&md).unwrap();
17473
17474        let items = rt.content[0].content.as_ref().unwrap();
17475        assert_eq!(items.len(), 1);
17476        let children = items[0].content.as_ref().unwrap();
17477        assert_eq!(
17478            children.len(),
17479            3,
17480            "Expected 3 paragraphs in listItem, got {}",
17481            children.len()
17482        );
17483        assert_eq!(children[0].node_type, "paragraph");
17484        assert_eq!(children[1].node_type, "paragraph");
17485        assert_eq!(children[2].node_type, "paragraph");
17486
17487        // Verify the text content of each paragraph
17488        let text1 = children[1].content.as_ref().unwrap()[0]
17489            .text
17490            .as_deref()
17491            .unwrap();
17492        assert_eq!(text1, "second paragraph");
17493        let text2 = children[2].content.as_ref().unwrap()[0]
17494            .text
17495            .as_deref()
17496            .unwrap();
17497        assert_eq!(text2, "third paragraph");
17498    }
17499
17500    #[test]
17501    fn issue_522_ordered_list_hardbreak_then_paragraphs_roundtrips() {
17502        // Same scenario in an ordered list.
17503        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"first"},{"type":"hardBreak"},{"type":"text","text":"continued"}]},{"type":"paragraph","content":[{"type":"text","text":"second para"}]},{"type":"paragraph","content":[{"type":"text","text":"third para"}]}]}]}]}"#;
17504        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17505        let md = adf_to_markdown(&doc).unwrap();
17506        let rt = markdown_to_adf(&md).unwrap();
17507
17508        let items = rt.content[0].content.as_ref().unwrap();
17509        let children = items[0].content.as_ref().unwrap();
17510        assert_eq!(
17511            children.len(),
17512            3,
17513            "Expected 3 paragraphs in ordered listItem, got {}",
17514            children.len()
17515        );
17516        assert_eq!(children[1].node_type, "paragraph");
17517        assert_eq!(children[2].node_type, "paragraph");
17518        assert_eq!(
17519            children[1].content.as_ref().unwrap()[0]
17520                .text
17521                .as_deref()
17522                .unwrap(),
17523            "second para"
17524        );
17525        assert_eq!(
17526            children[2].content.as_ref().unwrap()[0]
17527                .text
17528                .as_deref()
17529                .unwrap(),
17530            "third para"
17531        );
17532    }
17533
17534    #[test]
17535    fn issue_522_two_paragraphs_without_hardbreak_roundtrips() {
17536        // Two paragraphs without hardBreak — should also remain separate.
17537        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"first paragraph"}]},{"type":"paragraph","content":[{"type":"text","text":"second paragraph"}]}]}]}]}"#;
17538        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17539        let md = adf_to_markdown(&doc).unwrap();
17540        let rt = markdown_to_adf(&md).unwrap();
17541
17542        let items = rt.content[0].content.as_ref().unwrap();
17543        let children = items[0].content.as_ref().unwrap();
17544        assert_eq!(
17545            children.len(),
17546            2,
17547            "Expected 2 paragraphs in listItem, got {}",
17548            children.len()
17549        );
17550        assert_eq!(children[0].node_type, "paragraph");
17551        assert_eq!(children[1].node_type, "paragraph");
17552    }
17553
17554    #[test]
17555    fn issue_522_paragraph_then_nested_list_no_spurious_blank() {
17556        // A paragraph followed by a nested list should NOT get a blank
17557        // separator (only paragraph-paragraph transitions need one).
17558        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"parent"}]},{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"child"}]}]}]}]}]}]}"#;
17559        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17560        let md = adf_to_markdown(&doc).unwrap();
17561        // Should not contain a blank indented line between parent text and sub-list
17562        assert!(
17563            !md.contains("  \n  -"),
17564            "No blank separator between paragraph and nested list"
17565        );
17566        let rt = markdown_to_adf(&md).unwrap();
17567
17568        let items = rt.content[0].content.as_ref().unwrap();
17569        let children = items[0].content.as_ref().unwrap();
17570        assert_eq!(children.len(), 2);
17571        assert_eq!(children[0].node_type, "paragraph");
17572        assert_eq!(children[1].node_type, "bulletList");
17573    }
17574
17575    #[test]
17576    fn issue_522_three_paragraphs_no_hardbreak_roundtrips() {
17577        // Three plain paragraphs (no hardBreak) inside a single listItem.
17578        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"alpha"}]},{"type":"paragraph","content":[{"type":"text","text":"bravo"}]},{"type":"paragraph","content":[{"type":"text","text":"charlie"}]}]}]}]}"#;
17579        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17580        let md = adf_to_markdown(&doc).unwrap();
17581        let rt = markdown_to_adf(&md).unwrap();
17582
17583        let items = rt.content[0].content.as_ref().unwrap();
17584        let children = items[0].content.as_ref().unwrap();
17585        assert_eq!(
17586            children.len(),
17587            3,
17588            "Expected 3 paragraphs, got {}",
17589            children.len()
17590        );
17591        for (i, child) in children.iter().enumerate() {
17592            assert_eq!(
17593                child.node_type, "paragraph",
17594                "Child {} should be a paragraph",
17595                i
17596            );
17597        }
17598    }
17599
17600    #[test]
17601    fn issue_522_multiple_list_items_each_with_paragraphs() {
17602        // Multiple list items, each with multiple paragraphs.
17603        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"item1 p1"}]},{"type":"paragraph","content":[{"type":"text","text":"item1 p2"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"item2 p1"},{"type":"hardBreak"},{"type":"text","text":"item2 cont"}]},{"type":"paragraph","content":[{"type":"text","text":"item2 p2"}]}]}]}]}"#;
17604        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17605        let md = adf_to_markdown(&doc).unwrap();
17606        let rt = markdown_to_adf(&md).unwrap();
17607
17608        let items = rt.content[0].content.as_ref().unwrap();
17609        assert_eq!(items.len(), 2, "Expected 2 list items");
17610
17611        let item1 = items[0].content.as_ref().unwrap();
17612        assert_eq!(item1.len(), 2, "Item 1 should have 2 paragraphs");
17613
17614        let item2 = items[1].content.as_ref().unwrap();
17615        assert_eq!(item2.len(), 2, "Item 2 should have 2 paragraphs");
17616        // Verify hardBreak is preserved in item2's first paragraph
17617        let item2_p1_inlines = item2[0].content.as_ref().unwrap();
17618        let types: Vec<&str> = item2_p1_inlines
17619            .iter()
17620            .map(|n| n.node_type.as_str())
17621            .collect();
17622        assert_eq!(types, vec!["text", "hardBreak", "text"]);
17623    }
17624
17625    #[test]
17626    fn issue_531_blockquote_hardbreak_then_two_paragraphs_roundtrips() {
17627        // The exact reproducer from issue #531: blockquote with first
17628        // paragraph containing hardBreak nodes, followed by two sibling
17629        // paragraphs.
17630        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"preamble"},{"type":"hardBreak"},{"type":"text","text":"\u00a0"},{"type":"hardBreak"},{"type":"text","text":"line with "},{"marks":[{"type":"code"}],"text":"code","type":"text"},{"type":"text","text":". "}]},{"type":"paragraph","content":[{"type":"text","text":"second paragraph"}]},{"type":"paragraph","content":[{"type":"text","text":"third paragraph"}]}]}]}"#;
17631        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17632        let md = adf_to_markdown(&doc).unwrap();
17633        let rt = markdown_to_adf(&md).unwrap();
17634
17635        let children = rt.content[0].content.as_ref().unwrap();
17636        assert_eq!(
17637            children.len(),
17638            3,
17639            "Expected 3 paragraphs in blockquote, got {}",
17640            children.len()
17641        );
17642        assert_eq!(children[0].node_type, "paragraph");
17643        assert_eq!(children[1].node_type, "paragraph");
17644        assert_eq!(children[2].node_type, "paragraph");
17645
17646        let text1 = children[1].content.as_ref().unwrap()[0]
17647            .text
17648            .as_deref()
17649            .unwrap();
17650        assert_eq!(text1, "second paragraph");
17651        let text2 = children[2].content.as_ref().unwrap()[0]
17652            .text
17653            .as_deref()
17654            .unwrap();
17655        assert_eq!(text2, "third paragraph");
17656    }
17657
17658    #[test]
17659    fn issue_531_blockquote_two_paragraphs_without_hardbreak_roundtrips() {
17660        // Two simple paragraphs inside a blockquote, no hardBreak.
17661        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"first"}]},{"type":"paragraph","content":[{"type":"text","text":"second"}]}]}]}"#;
17662        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17663        let md = adf_to_markdown(&doc).unwrap();
17664        let rt = markdown_to_adf(&md).unwrap();
17665
17666        let children = rt.content[0].content.as_ref().unwrap();
17667        assert_eq!(
17668            children.len(),
17669            2,
17670            "Expected 2 paragraphs in blockquote, got {}",
17671            children.len()
17672        );
17673        assert_eq!(children[0].node_type, "paragraph");
17674        assert_eq!(children[1].node_type, "paragraph");
17675    }
17676
17677    #[test]
17678    fn issue_531_blockquote_three_paragraphs_no_hardbreak_roundtrips() {
17679        // Three paragraphs, none with hardBreak.
17680        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"alpha"}]},{"type":"paragraph","content":[{"type":"text","text":"beta"}]},{"type":"paragraph","content":[{"type":"text","text":"gamma"}]}]}]}"#;
17681        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17682        let md = adf_to_markdown(&doc).unwrap();
17683        let rt = markdown_to_adf(&md).unwrap();
17684
17685        let children = rt.content[0].content.as_ref().unwrap();
17686        assert_eq!(
17687            children.len(),
17688            3,
17689            "Expected 3 paragraphs in blockquote, got {}",
17690            children.len()
17691        );
17692        for child in children {
17693            assert_eq!(child.node_type, "paragraph");
17694        }
17695    }
17696
17697    #[test]
17698    fn issue_531_blockquote_paragraph_then_list_no_spurious_blank() {
17699        // A paragraph followed by a nested list inside a blockquote —
17700        // should NOT insert a blank separator line.
17701        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"intro"}]},{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"item one"}]}]}]}]}]}"#;
17702        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17703        let md = adf_to_markdown(&doc).unwrap();
17704        let rt = markdown_to_adf(&md).unwrap();
17705
17706        let children = rt.content[0].content.as_ref().unwrap();
17707        assert_eq!(children[0].node_type, "paragraph");
17708        assert_eq!(children[1].node_type, "bulletList");
17709    }
17710
17711    #[test]
17712    fn issue_531_blockquote_single_paragraph_unchanged() {
17713        // A single paragraph in a blockquote should remain unchanged.
17714        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"solo"}]}]}]}"#;
17715        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17716        let md = adf_to_markdown(&doc).unwrap();
17717        let rt = markdown_to_adf(&md).unwrap();
17718
17719        let children = rt.content[0].content.as_ref().unwrap();
17720        assert_eq!(children.len(), 1);
17721        assert_eq!(children[0].node_type, "paragraph");
17722        let text = children[0].content.as_ref().unwrap()[0]
17723            .text
17724            .as_deref()
17725            .unwrap();
17726        assert_eq!(text, "solo");
17727    }
17728}