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::{format_kv, parse_attrs, 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            if !self.try_append_hardbreak_continuation(full_text) {
73                break;
74            }
75        }
76    }
77
78    /// If the current line is a valid hardBreak continuation (2-space indented
79    /// and not a block-level sibling marker), append it to `full_text` and
80    /// advance the parser.  Returns `true` on success, `false` otherwise.
81    ///
82    /// Split out from `collect_hardbreak_continuations` so the body appears
83    /// as its own function in coverage reports (issue #552 PR coverage gap).
84    fn try_append_hardbreak_continuation(&mut self, full_text: &mut String) -> bool {
85        // Skip indented block-level siblings — mediaSingle (`![` — issue
86        // #490), fenced code blocks (```` ``` ```` — issue #552), and
87        // container directives (`:::`).  They must stay available for their
88        // dedicated block handlers instead of being merged into paragraph
89        // text.
90        match self
91            .current_line()
92            .strip_prefix("  ")
93            .filter(|s| !is_block_level_continuation_marker(s.trim_start()))
94        {
95            Some(stripped) => {
96                full_text.push('\n');
97                full_text.push_str(stripped);
98                self.advance();
99                true
100            }
101            None => false,
102        }
103    }
104
105    fn parse_blocks(&mut self) -> Result<Vec<AdfNode>> {
106        let mut blocks = Vec::new();
107
108        while !self.at_end() {
109            let line = self.current_line();
110
111            if line.trim().is_empty() {
112                self.advance();
113                continue;
114            }
115
116            let mut node = if let Some(node) = self.try_heading() {
117                node
118            } else if let Some(node) = self.try_horizontal_rule() {
119                node
120            } else if let Some(node) = self.try_container_directive()? {
121                node
122            } else if let Some(node) = self.try_code_block()? {
123                node
124            } else if let Some(node) = self.try_table()? {
125                node
126            } else if let Some(node) = self.try_blockquote()? {
127                node
128            } else if let Some(node) = self.try_list()? {
129                node
130            } else if let Some(node) = self.try_leaf_directive() {
131                node
132            } else if let Some(node) = self.try_image() {
133                node
134            } else {
135                self.parse_paragraph()?
136            };
137
138            // Check for trailing block-level {attrs} (align, indent, breakout)
139            self.try_apply_block_attrs(&mut node);
140            blocks.push(node);
141        }
142
143        Ok(blocks)
144    }
145
146    fn try_heading(&mut self) -> Option<AdfNode> {
147        let line = self.current_line();
148        let trimmed = line.trim_start();
149
150        if !trimmed.starts_with('#') {
151            return None;
152        }
153
154        let level = trimmed.chars().take_while(|&c| c == '#').count();
155        if !(1..=6).contains(&level) || !trimmed[level..].starts_with(' ') {
156            return None;
157        }
158
159        let mut full_text = trimmed[level + 1..].to_string();
160        self.advance();
161        // Collect indented continuation lines produced by hardBreaks (issue #433).
162        self.collect_hardbreak_continuations(&mut full_text);
163        let inline_nodes = parse_inline(&full_text);
164
165        #[allow(clippy::cast_possible_truncation)]
166        Some(AdfNode::heading(level as u8, inline_nodes))
167    }
168
169    fn try_horizontal_rule(&mut self) -> Option<AdfNode> {
170        let line = self.current_line().trim();
171        let is_rule = (line.starts_with("---") && line.chars().all(|c| c == '-'))
172            || (line.starts_with("***") && line.chars().all(|c| c == '*'))
173            || (line.starts_with("___") && line.chars().all(|c| c == '_'));
174
175        if is_rule && line.len() >= 3 {
176            self.advance();
177            Some(AdfNode::rule())
178        } else {
179            None
180        }
181    }
182
183    fn try_code_block(&mut self) -> Result<Option<AdfNode>> {
184        let line = self.current_line();
185        if !is_code_fence_opener(line) {
186            return Ok(None);
187        }
188
189        let language = line[3..].trim();
190        let language = if language == "\"\"" {
191            // Explicit empty language attr encoded as ```""
192            Some(String::new())
193        } else if language.is_empty() {
194            None
195        } else {
196            Some(language.to_string())
197        };
198
199        self.advance();
200        let mut code_lines = Vec::new();
201
202        while !self.at_end() {
203            let line = self.current_line();
204            if line.starts_with("```") {
205                self.advance();
206                break;
207            }
208            code_lines.push(line);
209            self.advance();
210        }
211
212        let code_text = code_lines.join("\n");
213
214        // If the language is "adf-unsupported", deserialize the JSON back to an AdfNode
215        if language.as_deref() == Some("adf-unsupported") {
216            if let Ok(node) = serde_json::from_str::<AdfNode>(&code_text) {
217                return Ok(Some(node));
218            }
219        }
220
221        Ok(Some(AdfNode::code_block(language.as_deref(), &code_text)))
222    }
223
224    fn try_blockquote(&mut self) -> Result<Option<AdfNode>> {
225        let line = self.current_line();
226        if !line.starts_with('>') {
227            return Ok(None);
228        }
229
230        let mut quote_lines = Vec::new();
231        while !self.at_end() {
232            let line = self.current_line();
233            if let Some(rest) = line.strip_prefix("> ") {
234                quote_lines.push(rest);
235                self.advance();
236            } else if let Some(rest) = line.strip_prefix('>') {
237                quote_lines.push(rest);
238                self.advance();
239            } else {
240                break;
241            }
242        }
243
244        let quote_text = quote_lines.join("\n");
245        let mut inner_parser = MarkdownParser::new(&quote_text);
246        let inner_blocks = inner_parser.parse_blocks()?;
247
248        Ok(Some(AdfNode::blockquote(inner_blocks)))
249    }
250
251    fn try_list(&mut self) -> Result<Option<AdfNode>> {
252        let line = self.current_line();
253        let trimmed = line.trim_start();
254
255        let is_bullet =
256            trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ");
257        let ordered_match = parse_ordered_list_marker(trimmed);
258
259        if !is_bullet && ordered_match.is_none() {
260            return Ok(None);
261        }
262
263        if is_bullet {
264            self.parse_bullet_list()
265        } else {
266            let start = ordered_match.map_or(1, |(n, _)| n);
267            self.parse_ordered_list(start)
268        }
269    }
270
271    fn parse_bullet_list(&mut self) -> Result<Option<AdfNode>> {
272        let mut items = Vec::new();
273        let mut is_task_list = false;
274
275        while !self.at_end() {
276            let line = self.current_line();
277            let trimmed = line.trim_start();
278
279            if !(trimmed.starts_with("- ")
280                || trimmed.starts_with("* ")
281                || trimmed.starts_with("+ "))
282            {
283                break;
284            }
285
286            let after_marker = trimmed[2..].trim_start();
287
288            // Detect task list items: - [ ] or - [x]
289            if let Some((state, text)) = try_parse_task_marker(after_marker) {
290                is_task_list = true;
291                self.advance();
292                // Collect hardBreak continuation lines so that a trailing
293                // {localId=…} on the last continuation line is found by
294                // extract_trailing_local_id (issue #507).
295                let mut full_text = text.to_string();
296                self.collect_hardbreak_continuations(&mut full_text);
297                let (item_text, local_id, para_local_id) = extract_trailing_local_id(&full_text);
298                let inline_nodes = parse_inline(item_text);
299                // If a paraLocalId marker is present the original ADF had a
300                // paragraph wrapper around the inline content — restore it
301                // so the round-trip is lossless (issue #478).
302                let content = if let Some(ref plid) = para_local_id {
303                    let mut para = AdfNode::paragraph(inline_nodes);
304                    if plid != "_" {
305                        para.attrs = Some(serde_json::json!({"localId": plid}));
306                    }
307                    vec![para]
308                } else {
309                    inline_nodes
310                };
311                let mut task = AdfNode::task_item(state, content);
312                // Override the placeholder localId if one was parsed
313                if let Some(id) = local_id {
314                    if let Some(ref mut attrs) = task.attrs {
315                        attrs["localId"] = serde_json::Value::String(id);
316                    }
317                }
318                // Collect indented sub-content (e.g. nested task lists
319                // from malformed ADF where taskItem contains taskItem
320                // children directly — issue #489).
321                let mut sub_lines: Vec<String> = Vec::new();
322                while !self.at_end() && self.current_line().starts_with("  ") {
323                    let stripped = &self.current_line()[2..];
324                    sub_lines.push(stripped.to_string());
325                    self.advance();
326                }
327                if !sub_lines.is_empty() {
328                    let sub_text = sub_lines.join("\n");
329                    let mut nested = MarkdownParser::new(&sub_text).parse_blocks()?;
330                    // When the task item has no inline text and its
331                    // sub-content is a single taskList, this is a
332                    // container taskItem from malformed ADF (issue #489).
333                    // Unwrap the taskList so the taskItem children sit
334                    // directly in the container, and drop the spurious
335                    // `state` attr that was injected by the checkbox
336                    // marker.
337                    let is_empty = task.content.as_ref().map_or(true, Vec::is_empty);
338                    if is_empty && nested.len() == 1 && nested[0].node_type == "taskList" {
339                        if let Some(task_items) = nested.remove(0).content {
340                            task.content = Some(task_items);
341                        }
342                        if let Some(ref mut attrs) = task.attrs {
343                            if let Some(obj) = attrs.as_object_mut() {
344                                obj.remove("state");
345                            }
346                        }
347                        items.push(task);
348                    } else {
349                        // Separate nested taskList nodes from other block
350                        // content.  Nested taskLists become sibling children
351                        // of the outer taskList rather than children of this
352                        // taskItem, matching ADF's representation of indented
353                        // sub-lists (issue #506).
354                        let mut sibling_task_lists = Vec::new();
355                        let mut child_nodes = Vec::new();
356                        for n in nested {
357                            if n.node_type == "taskList" {
358                                sibling_task_lists.push(n);
359                            } else {
360                                child_nodes.push(n);
361                            }
362                        }
363                        if !child_nodes.is_empty() {
364                            match task.content {
365                                Some(ref mut content) => content.append(&mut child_nodes),
366                                None => task.content = Some(child_nodes),
367                            }
368                        }
369                        items.push(task);
370                        items.append(&mut sibling_task_lists);
371                    }
372                } else {
373                    items.push(task);
374                }
375            } else {
376                let first_line = &trimmed[2..];
377                self.advance();
378                let mut full_text = first_line.to_string();
379                self.collect_hardbreak_continuations(&mut full_text);
380                let (item_text, local_id, para_local_id) = extract_trailing_local_id(&full_text);
381                // Collect indented sub-content lines (2-space prefix).
382                // This captures both nested lists and continuation
383                // paragraphs that belong to the same list item.
384                let mut sub_lines: Vec<String> = Vec::new();
385                while !self.at_end() {
386                    let next = self.current_line();
387                    if let Some(stripped) = next.strip_prefix("  ") {
388                        sub_lines.push(stripped.to_string());
389                        self.advance();
390                        continue;
391                    }
392                    break;
393                }
394                let item_content =
395                    parse_list_item_first_line(item_text, sub_lines, local_id, para_local_id)?;
396                items.push(item_content);
397            }
398        }
399
400        if items.is_empty() {
401            Ok(None)
402        } else if is_task_list {
403            Ok(Some(AdfNode::task_list(items)))
404        } else {
405            Ok(Some(AdfNode::bullet_list(items)))
406        }
407    }
408
409    fn parse_ordered_list(&mut self, start: u32) -> Result<Option<AdfNode>> {
410        let mut items = Vec::new();
411
412        while !self.at_end() {
413            let line = self.current_line();
414            let trimmed = line.trim_start();
415
416            if let Some((_, rest)) = parse_ordered_list_marker(trimmed) {
417                let first_line = rest.trim_start_matches(|c: char| c.is_ascii_whitespace());
418                self.advance();
419                let mut full_text = first_line.to_string();
420                self.collect_hardbreak_continuations(&mut full_text);
421                let (item_text, local_id, para_local_id) = extract_trailing_local_id(&full_text);
422                // Collect indented sub-content lines (2-space prefix).
423                let mut sub_lines: Vec<String> = Vec::new();
424                while !self.at_end() {
425                    let next = self.current_line();
426                    if let Some(stripped) = next.strip_prefix("  ") {
427                        sub_lines.push(stripped.to_string());
428                        self.advance();
429                        continue;
430                    }
431                    break;
432                }
433                let item_content =
434                    parse_list_item_first_line(item_text, sub_lines, local_id, para_local_id)?;
435                items.push(item_content);
436            } else {
437                break;
438            }
439        }
440
441        if items.is_empty() {
442            Ok(None)
443        } else {
444            let order = if start == 1 { None } else { Some(start) };
445            Ok(Some(AdfNode::ordered_list(items, order)))
446        }
447    }
448
449    fn try_apply_block_attrs(&mut self, node: &mut AdfNode) {
450        if self.at_end() {
451            return;
452        }
453        let line = self.current_line().trim();
454        if !line.starts_with('{') {
455            return;
456        }
457        let Some((_, attrs)) = parse_attrs(line, 0) else {
458            return;
459        };
460
461        let mut marks = Vec::new();
462        if let Some(align) = attrs.get("align") {
463            marks.push(AdfMark::alignment(align));
464        }
465        if let Some(indent) = attrs.get("indent") {
466            if let Ok(level) = indent.parse::<u32>() {
467                marks.push(AdfMark::indentation(level));
468            }
469        }
470        if let Some(mode) = attrs.get("breakout") {
471            let width = attrs
472                .get("breakoutWidth")
473                .and_then(|w| w.parse::<u32>().ok());
474            marks.push(AdfMark::breakout(mode, width));
475        }
476
477        // Parse localId from block attrs
478        let local_id = attrs.get("localId").map(str::to_string);
479
480        // Parse explicit order for orderedList nodes (issue #547).
481        let order = if node.node_type == "orderedList" {
482            attrs.get("order").and_then(|v| v.parse::<u32>().ok())
483        } else {
484            None
485        };
486
487        let has_attrs = !marks.is_empty() || local_id.is_some() || order.is_some();
488        if has_attrs {
489            if !marks.is_empty() {
490                let existing = node.marks.get_or_insert_with(Vec::new);
491                existing.extend(marks);
492            }
493            if let Some(id) = local_id {
494                let node_attrs = node.attrs.get_or_insert_with(|| serde_json::json!({}));
495                node_attrs["localId"] = serde_json::Value::String(id);
496            }
497            if let Some(n) = order {
498                let node_attrs = node.attrs.get_or_insert_with(|| serde_json::json!({}));
499                node_attrs["order"] = serde_json::json!(n);
500            }
501            self.advance(); // consume the attrs line
502        }
503    }
504
505    fn try_container_directive(&mut self) -> Result<Option<AdfNode>> {
506        let line = self.current_line();
507        let Some((d, colon_count)) = try_parse_container_open(line) else {
508            return Ok(None);
509        };
510        self.advance(); // past opening fence
511
512        // Collect inner lines until the matching close fence, tracking nesting
513        let mut inner_lines = Vec::new();
514        let mut depth: usize = 0;
515        while !self.at_end() {
516            let current = self.current_line();
517            if try_parse_container_open(current).is_some() {
518                depth += 1;
519            } else if depth == 0 && is_container_close(current, colon_count) {
520                self.advance(); // past closing fence
521                break;
522            } else if depth > 0 && is_container_close(current, 3) {
523                depth -= 1;
524            }
525            inner_lines.push(current.to_string());
526            self.advance();
527        }
528
529        let inner_text = inner_lines.join("\n");
530
531        let node = match d.name.as_str() {
532            "panel" => {
533                let panel_type = d
534                    .attrs
535                    .as_ref()
536                    .and_then(|a| a.get("type"))
537                    .unwrap_or("info");
538                let inner_blocks = MarkdownParser::new(&inner_text).parse_blocks()?;
539                let mut node = AdfNode::panel(panel_type, inner_blocks);
540                // Pass through custom panel attrs (icon, color)
541                if let Some(ref attrs) = d.attrs {
542                    if let Some(ref mut node_attrs) = node.attrs {
543                        if let Some(icon) = attrs.get("icon") {
544                            node_attrs["panelIcon"] = serde_json::Value::String(icon.to_string());
545                        }
546                        if let Some(color) = attrs.get("color") {
547                            node_attrs["panelColor"] = serde_json::Value::String(color.to_string());
548                        }
549                    }
550                }
551                node
552            }
553            "expand" => {
554                let title = d.attrs.as_ref().and_then(|a| a.get("title"));
555                let inner_blocks = MarkdownParser::new(&inner_text).parse_blocks()?;
556                let mut node = AdfNode::expand(title, inner_blocks);
557                pass_through_expand_params(&d.attrs, &mut node);
558                node
559            }
560            "nested-expand" => {
561                let title = d.attrs.as_ref().and_then(|a| a.get("title"));
562                let inner_blocks = MarkdownParser::new(&inner_text).parse_blocks()?;
563                let mut node = AdfNode::nested_expand(title, inner_blocks);
564                pass_through_expand_params(&d.attrs, &mut node);
565                node
566            }
567            "layout" => {
568                // Parse inner content looking for :::column sub-containers
569                let columns = self.parse_layout_columns(&inner_text)?;
570                AdfNode::layout_section(columns)
571            }
572            "decisions" => {
573                let items = parse_decision_items(&inner_text);
574                AdfNode::decision_list(items)
575            }
576            "table" => {
577                let rows = self.parse_directive_table_rows(&inner_text)?;
578                let mut table_attrs = serde_json::json!({});
579                if let Some(ref attrs) = d.attrs {
580                    if let Some(layout) = attrs.get("layout") {
581                        table_attrs["layout"] = serde_json::Value::String(layout.to_string());
582                    }
583                    if attrs.has_flag("numbered") {
584                        table_attrs["isNumberColumnEnabled"] = serde_json::json!(true);
585                    } else if attrs.get("numbered") == Some("false") {
586                        table_attrs["isNumberColumnEnabled"] = serde_json::json!(false);
587                    }
588                    if let Some(tw) = attrs.get("width") {
589                        if let Some(w) = parse_numeric_attr(tw) {
590                            table_attrs["width"] = w;
591                        }
592                    }
593                    if let Some(local_id) = attrs.get("localId") {
594                        table_attrs["localId"] = serde_json::Value::String(local_id.to_string());
595                    }
596                }
597                if table_attrs == serde_json::json!({}) {
598                    AdfNode::table(rows)
599                } else {
600                    AdfNode::table_with_attrs(rows, table_attrs)
601                }
602            }
603            "extension" => {
604                let ext_type = d.attrs.as_ref().and_then(|a| a.get("type")).unwrap_or("");
605                let ext_key = d.attrs.as_ref().and_then(|a| a.get("key")).unwrap_or("");
606                let inner_blocks = MarkdownParser::new(&inner_text).parse_blocks()?;
607                let mut node = AdfNode::bodied_extension(ext_type, ext_key, inner_blocks);
608                if let (Some(ref dir_attrs), Some(ref mut node_attrs)) = (&d.attrs, &mut node.attrs)
609                {
610                    if let Some(layout) = dir_attrs.get("layout") {
611                        node_attrs["layout"] = serde_json::Value::String(layout.to_string());
612                    }
613                    if let Some(local_id) = dir_attrs.get("localId") {
614                        node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
615                    }
616                    if let Some(params_str) = dir_attrs.get("params") {
617                        if let Ok(params_val) =
618                            serde_json::from_str::<serde_json::Value>(params_str)
619                        {
620                            node_attrs["parameters"] = params_val;
621                        }
622                    }
623                }
624                node
625            }
626            _ => return Ok(None),
627        };
628
629        Ok(Some(node))
630    }
631
632    fn parse_layout_columns(&self, inner_text: &str) -> Result<Vec<AdfNode>> {
633        let mut columns = Vec::new();
634        let mut current_column_lines: Vec<String> = Vec::new();
635        let mut current_width: serde_json::Value = serde_json::json!(50);
636        let mut current_dir_attrs: Option<crate::atlassian::attrs::Attrs> = None;
637        let mut in_column = false;
638        let mut depth: usize = 0;
639
640        let lines: Vec<&str> = inner_text.lines().collect();
641        let mut i = 0;
642
643        while i < lines.len() {
644            let line = lines[i];
645            if let Some((col_d, _)) = try_parse_container_open(line) {
646                if col_d.name == "column" && depth == 0 {
647                    // Flush previous column
648                    if in_column && !current_column_lines.is_empty() {
649                        let col_text = current_column_lines.join("\n");
650                        let blocks = MarkdownParser::new(&col_text).parse_blocks()?;
651                        let mut col = AdfNode::layout_column(current_width.clone(), blocks);
652                        pass_through_local_id(&current_dir_attrs, &mut col);
653                        columns.push(col);
654                        current_column_lines.clear();
655                    }
656                    current_width = col_d
657                        .attrs
658                        .as_ref()
659                        .and_then(|a| a.get("width"))
660                        .and_then(parse_numeric_attr)
661                        .unwrap_or_else(|| serde_json::json!(50));
662                    current_dir_attrs = col_d.attrs;
663                    in_column = true;
664                    i += 1;
665                    continue;
666                }
667                if in_column {
668                    depth += 1;
669                }
670            }
671            if in_column && is_container_close(line, 3) {
672                if depth > 0 {
673                    depth -= 1;
674                    current_column_lines.push(line.to_string());
675                    i += 1;
676                    continue;
677                }
678                // End of column
679                let col_text = current_column_lines.join("\n");
680                let blocks = MarkdownParser::new(&col_text).parse_blocks()?;
681                let mut col = AdfNode::layout_column(current_width.clone(), blocks);
682                pass_through_local_id(&current_dir_attrs, &mut col);
683                columns.push(col);
684                current_column_lines.clear();
685                current_dir_attrs = None;
686                in_column = false;
687                i += 1;
688                continue;
689            }
690            if in_column {
691                current_column_lines.push(line.to_string());
692            }
693            i += 1;
694        }
695
696        // Flush last column if no closing fence
697        if in_column && !current_column_lines.is_empty() {
698            let col_text = current_column_lines.join("\n");
699            let blocks = MarkdownParser::new(&col_text).parse_blocks()?;
700            let mut col = AdfNode::layout_column(current_width, blocks);
701            pass_through_local_id(&current_dir_attrs, &mut col);
702            columns.push(col);
703        }
704
705        Ok(columns)
706    }
707
708    /// Parses `:::tr` / `:::th` / `:::td` sub-containers inside a `:::table` directive.
709    fn parse_directive_table_rows(&self, inner_text: &str) -> Result<Vec<AdfNode>> {
710        debug!(
711            "parse_directive_table_rows: {} lines of inner text",
712            inner_text.lines().count()
713        );
714        let mut rows = Vec::new();
715        let lines: Vec<&str> = inner_text.lines().collect();
716        let mut i = 0;
717
718        while i < lines.len() {
719            let line = lines[i];
720            if let Some((d, _)) = try_parse_container_open(line) {
721                if d.name == "tr" {
722                    let tr_attrs = d.attrs.clone();
723                    i += 1;
724                    let (mut row, next_i) = self.parse_directive_table_row(&lines, i)?;
725                    // Pass through localId from :::tr{localId=...}
726                    if let Some(ref attrs) = tr_attrs {
727                        if let Some(local_id) = attrs.get("localId") {
728                            let row_attrs = row.attrs.get_or_insert_with(|| serde_json::json!({}));
729                            row_attrs["localId"] = serde_json::Value::String(local_id.to_string());
730                        }
731                    }
732                    rows.push(row);
733                    i = next_i;
734                    continue;
735                }
736                if d.name == "caption" {
737                    let dir_attrs = d.attrs.clone();
738                    i += 1;
739                    let mut caption_lines = Vec::new();
740                    while i < lines.len() {
741                        if is_container_close(lines[i], 3) {
742                            i += 1;
743                            break;
744                        }
745                        caption_lines.push(lines[i]);
746                        i += 1;
747                    }
748                    let caption_text = caption_lines.join("\n");
749                    let inline_nodes = parse_inline(&caption_text);
750                    let mut caption = AdfNode::caption(inline_nodes);
751                    pass_through_local_id(&dir_attrs, &mut caption);
752                    rows.push(caption);
753                    continue;
754                }
755            }
756            i += 1;
757        }
758
759        Ok(rows)
760    }
761
762    /// Parses cells within a `:::tr` container until its closing fence.
763    fn parse_directive_table_row(&self, lines: &[&str], start: usize) -> Result<(AdfNode, usize)> {
764        let mut cells = Vec::new();
765        let mut i = start;
766        let mut depth: usize = 0;
767
768        while i < lines.len() {
769            let line = lines[i];
770            if is_container_close(line, 3) {
771                if depth == 0 {
772                    // End of :::tr
773                    i += 1;
774                    break;
775                }
776                depth -= 1;
777                i += 1;
778                continue;
779            }
780            if let Some((d, _)) = try_parse_container_open(line) {
781                if depth == 0 && (d.name == "th" || d.name == "td") {
782                    let is_header = d.name == "th";
783                    let cell_attrs = d.attrs.clone();
784                    i += 1;
785                    let (cell, next_i) =
786                        self.parse_directive_table_cell(lines, i, is_header, cell_attrs)?;
787                    cells.push(cell);
788                    i = next_i;
789                    continue;
790                }
791                depth += 1;
792            }
793            i += 1;
794        }
795
796        if cells.is_empty() {
797            let context = lines[start.saturating_sub(1)..lines.len().min(start + 3)].to_vec();
798            warn!(
799                "Directive table row at line {start} has no cells — \
800                 Confluence requires at least one. Nearby lines: {context:?}"
801            );
802        }
803        debug!("Parsed directive table row: {} cells", cells.len());
804
805        Ok((AdfNode::table_row(cells), i))
806    }
807
808    /// Parses the content of a `:::th` or `:::td` cell until its closing fence.
809    fn parse_directive_table_cell(
810        &self,
811        lines: &[&str],
812        start: usize,
813        is_header: bool,
814        cell_attrs: Option<crate::atlassian::attrs::Attrs>,
815    ) -> Result<(AdfNode, usize)> {
816        let mut cell_lines = Vec::new();
817        let mut i = start;
818        let mut depth: usize = 0;
819
820        while i < lines.len() {
821            let line = lines[i];
822            if try_parse_container_open(line).is_some() {
823                depth += 1;
824            } else if is_container_close(line, 3) {
825                if depth == 0 {
826                    i += 1;
827                    break;
828                }
829                depth -= 1;
830            }
831            cell_lines.push(line.to_string());
832            i += 1;
833        }
834
835        let cell_text = cell_lines.join("\n");
836        let blocks = MarkdownParser::new(&cell_text).parse_blocks()?;
837
838        let adf_attrs = cell_attrs.as_ref().map(build_cell_attrs);
839        let cell_marks = cell_attrs
840            .as_ref()
841            .map(build_border_marks)
842            .unwrap_or_default();
843
844        let cell = if cell_marks.is_empty() {
845            if is_header {
846                if let Some(attrs) = adf_attrs {
847                    AdfNode::table_header_with_attrs(blocks, attrs)
848                } else {
849                    AdfNode::table_header(blocks)
850                }
851            } else if let Some(attrs) = adf_attrs {
852                AdfNode::table_cell_with_attrs(blocks, attrs)
853            } else {
854                AdfNode::table_cell(blocks)
855            }
856        } else if is_header {
857            AdfNode::table_header_with_attrs_and_marks(blocks, adf_attrs, cell_marks)
858        } else {
859            AdfNode::table_cell_with_attrs_and_marks(blocks, adf_attrs, cell_marks)
860        };
861
862        Ok((cell, i))
863    }
864
865    fn try_leaf_directive(&mut self) -> Option<AdfNode> {
866        let line = self.current_line();
867        let d = try_parse_leaf_directive(line)?;
868
869        let node = match d.name.as_str() {
870            "card" => {
871                let content = d.content.as_deref().unwrap_or("");
872                // Prefer the `url` attribute when present; fall back to the
873                // bracketed content.  The attribute form is used when the URL
874                // contains characters that would otherwise break
875                // `::card[URL]` parsing.
876                let url = match d.attrs.as_ref().and_then(|a| a.get("url")) {
877                    Some(u) => u,
878                    None => content,
879                };
880                let mut node = AdfNode::block_card(url);
881                // Pass through layout/width attrs
882                if let Some(ref attrs) = d.attrs {
883                    if let Some(ref mut node_attrs) = node.attrs {
884                        if let Some(layout) = attrs.get("layout") {
885                            node_attrs["layout"] = serde_json::Value::String(layout.to_string());
886                        }
887                        if let Some(width) = attrs.get("width") {
888                            if let Ok(w) = width.parse::<u64>() {
889                                node_attrs["width"] = serde_json::json!(w);
890                            }
891                        }
892                    }
893                }
894                node
895            }
896            "embed" => {
897                let url = d.content.as_deref().unwrap_or("");
898                let layout = d.attrs.as_ref().and_then(|a| a.get("layout"));
899                let original_height = d
900                    .attrs
901                    .as_ref()
902                    .and_then(|a| a.get("originalHeight"))
903                    .and_then(|v| v.parse::<f64>().ok());
904                let width = d
905                    .attrs
906                    .as_ref()
907                    .and_then(|a| a.get("width"))
908                    .and_then(|w| w.parse::<f64>().ok());
909                AdfNode::embed_card(url, layout, original_height, width)
910            }
911            "extension" => {
912                let ext_type = d.attrs.as_ref().and_then(|a| a.get("type")).unwrap_or("");
913                let ext_key = d.attrs.as_ref().and_then(|a| a.get("key")).unwrap_or("");
914                let params = d
915                    .attrs
916                    .as_ref()
917                    .and_then(|a| a.get("params"))
918                    .and_then(|p| serde_json::from_str(p).ok());
919                let mut node = AdfNode::extension(ext_type, ext_key, params);
920                if let (Some(ref dir_attrs), Some(ref mut node_attrs)) = (&d.attrs, &mut node.attrs)
921                {
922                    if let Some(layout) = dir_attrs.get("layout") {
923                        node_attrs["layout"] = serde_json::Value::String(layout.to_string());
924                    }
925                    if let Some(local_id) = dir_attrs.get("localId") {
926                        node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
927                    }
928                }
929                node
930            }
931            "paragraph" => {
932                let mut node = if let Some(ref text) = d.content {
933                    AdfNode::paragraph(parse_inline(text))
934                } else {
935                    AdfNode::paragraph(vec![])
936                };
937                pass_through_local_id(&d.attrs, &mut node);
938                node
939            }
940            _ => return None,
941        };
942
943        self.advance();
944        Some(node)
945    }
946
947    fn try_image(&mut self) -> Option<AdfNode> {
948        let line = self.current_line().trim();
949        let mut node = try_parse_media_single_from_line(line)?;
950        self.advance();
951
952        // Check for a trailing :::caption directive
953        if !self.at_end() {
954            if let Some((d, _)) = try_parse_container_open(self.current_line()) {
955                if d.name == "caption" {
956                    let dir_attrs = d.attrs;
957                    self.advance(); // past :::caption
958                    let mut caption_lines = Vec::new();
959                    while !self.at_end() {
960                        if is_container_close(self.current_line(), 3) {
961                            self.advance(); // past :::
962                            break;
963                        }
964                        caption_lines.push(self.current_line());
965                        self.advance();
966                    }
967                    let caption_text = caption_lines.join("\n");
968                    let inline_nodes = parse_inline(&caption_text);
969                    let mut caption = AdfNode::caption(inline_nodes);
970                    pass_through_local_id(&dir_attrs, &mut caption);
971                    if let Some(ref mut content) = node.content {
972                        content.push(caption);
973                    }
974                }
975            }
976        }
977
978        Some(node)
979    }
980
981    fn try_table(&mut self) -> Result<Option<AdfNode>> {
982        let line = self.current_line();
983        if !line.contains('|') || !line.trim_start().starts_with('|') {
984            return Ok(None);
985        }
986
987        // Peek ahead to check for a separator row (indicates a table)
988        if self.pos + 1 >= self.lines.len() {
989            return Ok(None);
990        }
991        let next_line = self.lines[self.pos + 1];
992        if !is_table_separator(next_line) {
993            return Ok(None);
994        }
995
996        // Parse header row
997        let header_cells = parse_table_row(line);
998        self.advance(); // skip header
999
1000        // Parse separator row for column alignment
1001        let sep_line = self.current_line();
1002        let alignments = parse_table_alignments(sep_line);
1003        self.advance(); // skip separator
1004
1005        let mut rows = Vec::new();
1006
1007        // Header row — parse cell attrs and apply column alignment
1008        let header_adf_cells: Vec<AdfNode> = header_cells
1009            .iter()
1010            .enumerate()
1011            .map(|(col_idx, cell)| {
1012                let (cell_text, cell_attrs) = extract_cell_attrs(cell);
1013                let mut para = AdfNode::paragraph(parse_inline(&cell_text));
1014                apply_column_alignment(&mut para, alignments.get(col_idx).copied().flatten());
1015                if let Some(attrs) = cell_attrs {
1016                    AdfNode::table_header_with_attrs(vec![para], attrs)
1017                } else {
1018                    AdfNode::table_header(vec![para])
1019                }
1020            })
1021            .collect();
1022        if header_adf_cells.is_empty() {
1023            warn!(
1024                "Pipe table header row at line {} has no cells",
1025                self.pos - 1
1026            );
1027        }
1028        rows.push(AdfNode::table_row(header_adf_cells));
1029
1030        // Body rows
1031        while !self.at_end() {
1032            let line = self.current_line();
1033            if !line.contains('|') || line.trim().is_empty() {
1034                break;
1035            }
1036
1037            let cells = parse_table_row(line);
1038            let adf_cells: Vec<AdfNode> = cells
1039                .iter()
1040                .enumerate()
1041                .map(|(col_idx, cell)| {
1042                    let (cell_text, cell_attrs) = extract_cell_attrs(cell);
1043                    let mut para = AdfNode::paragraph(parse_inline(&cell_text));
1044                    apply_column_alignment(&mut para, alignments.get(col_idx).copied().flatten());
1045                    if let Some(attrs) = cell_attrs {
1046                        AdfNode::table_cell_with_attrs(vec![para], attrs)
1047                    } else {
1048                        AdfNode::table_cell(vec![para])
1049                    }
1050                })
1051                .collect();
1052            if adf_cells.is_empty() {
1053                warn!("Pipe table body row at line {} has no cells", self.pos);
1054            }
1055            rows.push(AdfNode::table_row(adf_cells));
1056            self.advance();
1057        }
1058
1059        debug!("Parsed pipe table with {} rows", rows.len());
1060        let mut table = AdfNode::table(rows);
1061
1062        // Check for trailing {attrs} on the next line
1063        if !self.at_end() {
1064            let next = self.current_line().trim();
1065            if next.starts_with('{') {
1066                if let Some((_, attrs)) = parse_attrs(next, 0) {
1067                    let mut table_attrs = serde_json::json!({});
1068                    if let Some(layout) = attrs.get("layout") {
1069                        table_attrs["layout"] = serde_json::Value::String(layout.to_string());
1070                    }
1071                    if attrs.has_flag("numbered") {
1072                        table_attrs["isNumberColumnEnabled"] = serde_json::json!(true);
1073                    } else if attrs.get("numbered") == Some("false") {
1074                        table_attrs["isNumberColumnEnabled"] = serde_json::json!(false);
1075                    }
1076                    if let Some(tw) = attrs.get("width") {
1077                        if let Some(w) = parse_numeric_attr(tw) {
1078                            table_attrs["width"] = w;
1079                        }
1080                    }
1081                    if let Some(local_id) = attrs.get("localId") {
1082                        table_attrs["localId"] = serde_json::Value::String(local_id.to_string());
1083                    }
1084                    if table_attrs != serde_json::json!({}) {
1085                        table.attrs = Some(table_attrs);
1086                        self.advance(); // consume the attrs line
1087                    }
1088                }
1089            }
1090        }
1091
1092        Ok(Some(table))
1093    }
1094
1095    fn parse_paragraph(&mut self) -> Result<AdfNode> {
1096        let mut lines: Vec<&str> = Vec::new();
1097
1098        while !self.at_end() {
1099            let line = self.current_line();
1100            // Only break on block-level patterns if we already have paragraph
1101            // content. This prevents infinite loops when a line looks like a
1102            // block starter but doesn't actually match any block parser (e.g.,
1103            // "#NoSpace" which is not a valid heading).
1104            // Issue #494: A whitespace-only line that follows a hardBreak
1105            // marker (trailing backslash or two trailing spaces) is a
1106            // continuation, not a paragraph break.  Let it fall through to
1107            // the `is_hardbreak_cont` check below.
1108            if (line.trim().is_empty()
1109                && !lines
1110                    .last()
1111                    .is_some_and(|prev| has_trailing_hard_break(prev)))
1112                || is_code_fence_opener(line)
1113                || (is_horizontal_rule(line) && !lines.is_empty())
1114            {
1115                break;
1116            }
1117            // Strip 2-space indent from hardBreak continuation lines so
1118            // the content round-trips correctly (issue #455).
1119            let is_hardbreak_cont = !lines.is_empty()
1120                && line.starts_with("  ")
1121                && lines
1122                    .last()
1123                    .is_some_and(|prev| has_trailing_hard_break(prev));
1124            if is_hardbreak_cont {
1125                lines.push(&line[2..]);
1126                self.advance();
1127                continue;
1128            }
1129            if !lines.is_empty()
1130                && (line.starts_with('#') || line.starts_with('>') || is_list_start(line))
1131            {
1132                break;
1133            }
1134            // Break on trailing block attrs like {align=center}
1135            if !lines.is_empty() && is_block_attrs_line(line) {
1136                break;
1137            }
1138            lines.push(line);
1139            self.advance();
1140        }
1141
1142        let text = lines.join("\n");
1143        let inline_nodes = parse_inline(&text);
1144        Ok(AdfNode::paragraph(inline_nodes))
1145    }
1146}
1147
1148/// Builds ADF cell attributes from JFM directive attrs.
1149/// Maps: `bg` → `background`, `colspan` → number, `rowspan` → number, `colwidth` → array.
1150fn build_cell_attrs(attrs: &crate::atlassian::attrs::Attrs) -> serde_json::Value {
1151    let mut adf = serde_json::json!({});
1152    if let Some(bg) = attrs.get("bg") {
1153        adf["background"] = serde_json::Value::String(bg.to_string());
1154    }
1155    if let Some(colspan) = attrs.get("colspan") {
1156        if let Ok(n) = colspan.parse::<u32>() {
1157            adf["colspan"] = serde_json::json!(n);
1158        }
1159    }
1160    if let Some(rowspan) = attrs.get("rowspan") {
1161        if let Ok(n) = rowspan.parse::<u32>() {
1162            adf["rowspan"] = serde_json::json!(n);
1163        }
1164    }
1165    if let Some(colwidth) = attrs.get("colwidth") {
1166        let widths: Vec<serde_json::Value> = colwidth
1167            .split(',')
1168            .filter_map(|s| parse_numeric_attr(s.trim()))
1169            .collect();
1170        if !widths.is_empty() {
1171            adf["colwidth"] = serde_json::Value::Array(widths);
1172        }
1173    }
1174    if let Some(local_id) = attrs.get("localId") {
1175        adf["localId"] = serde_json::Value::String(local_id.to_string());
1176    }
1177    adf
1178}
1179
1180/// Extracts border marks from directive attributes (used by table cells and media nodes).
1181fn build_border_marks(attrs: &crate::atlassian::attrs::Attrs) -> Vec<AdfMark> {
1182    let mut marks = Vec::new();
1183    let border_color = attrs.get("border-color");
1184    let border_size = attrs.get("border-size");
1185    if border_color.is_some() || border_size.is_some() {
1186        let color = border_color.unwrap_or("#000000");
1187        let size = border_size.and_then(|s| s.parse::<u32>().ok()).unwrap_or(1);
1188        marks.push(AdfMark::border(color, size));
1189    }
1190    marks
1191}
1192
1193/// Converts an ISO 8601 date string (e.g., "2026-04-15") to epoch milliseconds string.
1194/// If the input is already numeric (epoch ms), returns it unchanged.
1195fn iso_date_to_epoch_ms(date_str: &str) -> String {
1196    // If it's already a numeric timestamp, pass through
1197    if date_str.chars().all(|c| c.is_ascii_digit()) {
1198        return date_str.to_string();
1199    }
1200    if let Ok(date) = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
1201        let epoch_ms = date
1202            .and_hms_opt(0, 0, 0)
1203            .map_or(0, |dt| dt.and_utc().timestamp_millis());
1204        epoch_ms.to_string()
1205    } else {
1206        // Fallback: pass through as-is
1207        date_str.to_string()
1208    }
1209}
1210
1211/// Converts an epoch milliseconds string to an ISO 8601 date string.
1212/// If the input looks like an ISO date already, returns it unchanged.
1213fn epoch_ms_to_iso_date(timestamp: &str) -> String {
1214    // If it looks like an ISO date already, pass through
1215    if timestamp.contains('-') {
1216        return timestamp.to_string();
1217    }
1218    if let Ok(ms) = timestamp.parse::<i64>() {
1219        let secs = ms / 1000;
1220        if let Some(dt) = chrono::DateTime::from_timestamp(secs, 0) {
1221            return dt.format("%Y-%m-%d").to_string();
1222        }
1223    }
1224    // Fallback: pass through
1225    timestamp.to_string()
1226}
1227
1228/// Checks if a line is a standalone block-level attrs line like `{align=center}`.
1229fn is_block_attrs_line(line: &str) -> bool {
1230    let trimmed = line.trim();
1231    if !trimmed.starts_with('{') || !trimmed.ends_with('}') {
1232        return false;
1233    }
1234    if let Some((_, attrs)) = parse_attrs(trimmed, 0) {
1235        // Only consider it a block attrs line if it has recognized block attrs
1236        attrs.get("align").is_some()
1237            || attrs.get("indent").is_some()
1238            || attrs.get("breakout").is_some()
1239            || attrs.get("breakoutWidth").is_some()
1240            || attrs.get("localId").is_some()
1241    } else {
1242        false
1243    }
1244}
1245
1246/// Parses decision items from the inner content of a `:::decisions` container.
1247/// Each item starts with `- <> ` prefix.
1248fn parse_decision_items(text: &str) -> Vec<AdfNode> {
1249    let mut items = Vec::new();
1250    for line in text.lines() {
1251        let trimmed = line.trim();
1252        if let Some(rest) = trimmed.strip_prefix("- <> ") {
1253            let inline_nodes = parse_inline(rest);
1254            items.push(AdfNode::decision_item(
1255                "DECIDED",
1256                vec![AdfNode::paragraph(inline_nodes)],
1257            ));
1258        }
1259    }
1260    items
1261}
1262
1263/// Tries to parse a task list marker `[ ]`, `[x]`, or `[X]` at the start of text.
1264/// Returns `("TODO"|"DONE", remaining_text)` on success.
1265///
1266/// The marker must be followed by a space or the end of the text, so that
1267/// empty task items (`- [ ]` with no body) are still recognised as tasks
1268/// rather than being treated as bullet items containing literal `[ ]` text
1269/// (issue #548).
1270fn try_parse_task_marker(text: &str) -> Option<(&str, &str)> {
1271    if let Some(rest) = strip_task_checkbox(text, "[ ]") {
1272        Some(("TODO", rest))
1273    } else if let Some(rest) =
1274        strip_task_checkbox(text, "[x]").or_else(|| strip_task_checkbox(text, "[X]"))
1275    {
1276        Some(("DONE", rest))
1277    } else {
1278        None
1279    }
1280}
1281
1282/// Strips a checkbox prefix from `text` if the character after the checkbox
1283/// is a space or the text ends there.  Returns the remaining text (with the
1284/// separating space consumed, if any).
1285fn strip_task_checkbox<'a>(text: &'a str, checkbox: &str) -> Option<&'a str> {
1286    let rest = text.strip_prefix(checkbox)?;
1287    if rest.is_empty() {
1288        Some(rest)
1289    } else {
1290        rest.strip_prefix(' ')
1291    }
1292}
1293
1294/// Returns true if `s` begins with a sequence the bullet-list parser would
1295/// interpret as a task checkbox marker (`[ ]`, `[x]`, or `[X]` followed by
1296/// a space, newline, or end-of-input).
1297///
1298/// Used by the `bulletList` renderer to decide whether to escape the leading
1299/// `[` of an item whose literal text starts with a checkbox-shaped prefix
1300/// (issue #548).
1301fn starts_with_task_marker(s: &str) -> bool {
1302    let after = if let Some(rest) = s.strip_prefix("[ ]") {
1303        rest
1304    } else if let Some(rest) = s.strip_prefix("[x]").or_else(|| s.strip_prefix("[X]")) {
1305        rest
1306    } else {
1307        return false;
1308    };
1309    after.is_empty() || after.starts_with(' ') || after.starts_with('\n')
1310}
1311
1312/// Parses an ordered list marker like "1. " and returns (number, rest_of_line).
1313fn parse_ordered_list_marker(line: &str) -> Option<(u32, &str)> {
1314    let digit_end = line.find(|c: char| !c.is_ascii_digit())?;
1315    if digit_end == 0 {
1316        return None;
1317    }
1318    let rest = &line[digit_end..];
1319    let after_marker = rest.strip_prefix(". ")?;
1320    let num: u32 = line[..digit_end].parse().ok()?;
1321    Some((num, after_marker))
1322}
1323
1324/// Returns true if a line ends with a hardBreak marker
1325/// (trailing backslash or two trailing spaces).
1326fn has_trailing_hard_break(line: &str) -> bool {
1327    line.ends_with('\\') || line.ends_with("  ")
1328}
1329
1330/// Returns true if the already-trimmed continuation line starts with a
1331/// block-level marker that must not be swallowed as a paragraph continuation
1332/// in `collect_hardbreak_continuations`.
1333///
1334/// Covers mediaSingle (`![` — issue #490), fenced code blocks (```` ``` ````
1335/// — issue #552), and container directives (`:::`).  The caller is expected
1336/// to have already stripped leading whitespace.
1337fn is_block_level_continuation_marker(trimmed: &str) -> bool {
1338    trimmed.starts_with("![") || trimmed.starts_with("```") || trimmed.starts_with(":::")
1339}
1340
1341/// Checks if a line starts a list item.
1342fn is_list_start(line: &str) -> bool {
1343    let trimmed = line.trim_start();
1344    trimmed.starts_with("- ")
1345        || trimmed.starts_with("* ")
1346        || trimmed.starts_with("+ ")
1347        || parse_ordered_list_marker(trimmed).is_some()
1348}
1349
1350/// Escapes asterisk and underscore sequences in text that would otherwise be
1351/// parsed as CommonMark emphasis (`*…*`, `_…_`) or strong emphasis (`**…**`,
1352/// `__…__`).
1353///
1354/// Asterisks are always escaped (they're rare in prose and the JFM parser
1355/// will gladly match them across node boundaries). Underscores are escaped
1356/// per the intraword rule: a `_` is left as-is only when it's clearly
1357/// intraword *within this text node* (alphanumeric on both sides). At the
1358/// node boundary or next to non-alphanumeric characters we escape, since
1359/// adjacent text nodes can supply the other side of an emphasis pair (issue
1360/// #554: `"_ "` followed by colored `"_Action…"` produced `_ :span[_…` which
1361/// parsed as italic and destroyed the span directive).
1362fn escape_emphasis_markers(text: &str) -> String {
1363    escape_emphasis_with(text, false)
1364}
1365
1366/// Variant of [`escape_emphasis_markers`] that escapes ALL underscores (even
1367/// intraword), not just boundary ones.
1368///
1369/// Must be used whenever the rendered markdown wraps this text in an `_..._`
1370/// em delimiter, because an unescaped `_` anywhere in the content would
1371/// otherwise close the delimiter prematurely (e.g. `_foo_bar_baz_` parses as
1372/// em("foo") + "bar" + em("baz"), not em("foo_bar_baz")).
1373fn escape_emphasis_markers_with_underscore(text: &str) -> String {
1374    escape_emphasis_with(text, true)
1375}
1376
1377/// Internal: escapes `*` always, and escapes `_` per the CommonMark intraword
1378/// rule by default — boundary or punctuation-adjacent runs are escaped, fully
1379/// intraword runs are left as-is.  When `escape_underscore_always` is true,
1380/// every `_` is escaped regardless (used when the surrounding context is an
1381/// `_..._` em delimiter that any inner `_` would close prematurely).
1382fn escape_emphasis_with(text: &str, escape_underscore_always: bool) -> String {
1383    let chars: Vec<char> = text.chars().collect();
1384    let mut out = String::with_capacity(text.len());
1385    let mut idx = 0;
1386    while idx < chars.len() {
1387        let ch = chars[idx];
1388        if ch == '*' {
1389            out.push('\\');
1390            out.push(ch);
1391            idx += 1;
1392        } else if ch == '_' {
1393            // Find the extent of this run of underscores. CommonMark treats
1394            // consecutive `_` as a single delimiter run, so the intraword
1395            // check applies to the whole run, not individual characters.
1396            let run_start = idx;
1397            let mut run_end = idx;
1398            while run_end < chars.len() && chars[run_end] == '_' {
1399                run_end += 1;
1400            }
1401            let escape_run = if escape_underscore_always {
1402                true
1403            } else {
1404                let before_alnum = run_start > 0 && chars[run_start - 1].is_alphanumeric();
1405                let after_alnum = chars.get(run_end).is_some_and(|c| c.is_alphanumeric());
1406                !(before_alnum && after_alnum)
1407            };
1408            for _ in run_start..run_end {
1409                if escape_run {
1410                    out.push('\\');
1411                }
1412                out.push('_');
1413            }
1414            idx = run_end;
1415        } else {
1416            out.push(ch);
1417            idx += 1;
1418        }
1419    }
1420    out
1421}
1422
1423/// Escapes backtick characters in text that would otherwise be parsed as
1424/// inline code spans (`` `…` ``).
1425///
1426/// Each backtick is prefixed with a backslash so that the JFM parser treats
1427/// it as a literal character rather than an inline-code delimiter.
1428fn escape_backticks(text: &str) -> String {
1429    let mut out = String::with_capacity(text.len());
1430    for ch in text.chars() {
1431        if ch == '`' {
1432            out.push('\\');
1433        }
1434        out.push(ch);
1435    }
1436    out
1437}
1438
1439/// Chooses a backtick delimiter length and padding flag for rendering `text`
1440/// as a CommonMark inline code span.
1441///
1442/// Per CommonMark: the delimiter must be a run of backticks not equal in
1443/// length to any run inside the content, and if both ends of the content
1444/// would start/end with a space (or with a backtick), a single space of
1445/// padding is added so the span survives the spec's space-stripping rule.
1446fn inline_code_delimiter(text: &str) -> (usize, bool) {
1447    let mut max_run = 0usize;
1448    let mut current = 0usize;
1449    for ch in text.chars() {
1450        if ch == '`' {
1451            current += 1;
1452            if current > max_run {
1453                max_run = current;
1454            }
1455        } else {
1456            current = 0;
1457        }
1458    }
1459    let n = max_run + 1;
1460    let starts_bt = text.starts_with('`');
1461    let ends_bt = text.ends_with('`');
1462    let starts_sp = text.starts_with(' ');
1463    let ends_sp = text.ends_with(' ');
1464    let all_sp = !text.is_empty() && text.chars().all(|c| c == ' ');
1465    let needs_pad = starts_bt || ends_bt || (starts_sp && ends_sp && !all_sp);
1466    (n, needs_pad)
1467}
1468
1469/// Appends `text` to `output` wrapped in a CommonMark inline code span whose
1470/// delimiter length allows any embedded backticks to round-trip unambiguously.
1471fn render_inline_code(text: &str, output: &mut String) {
1472    let (n, pad) = inline_code_delimiter(text);
1473    for _ in 0..n {
1474        output.push('`');
1475    }
1476    if pad {
1477        output.push(' ');
1478    }
1479    output.push_str(text);
1480    if pad {
1481        output.push(' ');
1482    }
1483    for _ in 0..n {
1484        output.push('`');
1485    }
1486}
1487
1488/// Escapes pipe characters in text that appears inside a GFM pipe table cell.
1489///
1490/// Without this, a literal `|` in cell content (including inside inline code
1491/// spans) is interpreted as a column separator on round-trip, splitting the
1492/// cell and corrupting its content (see issue #579).  Each `|` is prefixed
1493/// with a backslash so the table-row parser treats it as literal.
1494fn escape_pipes_in_cell(text: &str) -> String {
1495    let mut out = String::with_capacity(text.len());
1496    for ch in text.chars() {
1497        if ch == '|' {
1498            out.push('\\');
1499        }
1500        out.push(ch);
1501    }
1502    out
1503}
1504
1505/// Escapes square brackets (`[` and `]`) in text that will appear inside a
1506/// markdown link's `[…]` delimiters.  Without this, a text node containing a
1507/// literal `[` or `]` can create ambiguous markdown link syntax on round-trip
1508/// (see issue #493).
1509fn escape_link_brackets(text: &str) -> String {
1510    let mut out = String::with_capacity(text.len());
1511    for ch in text.chars() {
1512        if ch == '[' || ch == ']' {
1513            out.push('\\');
1514        }
1515        out.push(ch);
1516    }
1517    out
1518}
1519
1520/// Escapes bare URLs (`http://` and `https://`) in plain text so they are not
1521/// parsed as `inlineCard` nodes during round-trip.  The leading `h` is
1522/// backslash-escaped, which is enough to prevent the auto-link detector from
1523/// matching the URL while the existing backslash-escape handler restores it on
1524/// re-parse.
1525fn escape_bare_urls(text: &str) -> String {
1526    let mut result = String::with_capacity(text.len());
1527    for (i, ch) in text.char_indices() {
1528        if ch == 'h' {
1529            let rest = &text[i..];
1530            if rest.starts_with("http://") || rest.starts_with("https://") {
1531                result.push('\\');
1532            }
1533        }
1534        result.push(ch);
1535    }
1536    result
1537}
1538
1539/// Returns `true` if the string can be embedded in a `:card[...]` (or similar
1540/// bracketed inline directive) without breaking the depth-based bracket matcher
1541/// used by [`try_parse_inline_directive`].
1542///
1543/// The parser scans the content enclosed in `[...]` treating `[` as +1 and `]`
1544/// as −1 on depth, closing when depth returns to zero.  A value is safe if
1545/// every prefix has `count('[') >= count(']') − 1` (i.e., the running depth
1546/// never dips below zero before the end) and it contains no newline.
1547fn url_safe_in_bracket_content(s: &str) -> bool {
1548    if s.contains('\n') {
1549        return false;
1550    }
1551    let mut depth: i32 = 1;
1552    for ch in s.chars() {
1553        match ch {
1554            '[' => depth += 1,
1555            ']' => {
1556                depth -= 1;
1557                if depth == 0 {
1558                    return false;
1559                }
1560            }
1561            _ => {}
1562        }
1563    }
1564    true
1565}
1566
1567/// Escapes emoji shortcode patterns (`:name:`) in plain text so they are not
1568/// parsed as emoji nodes during round-trip.  Only the leading colon is
1569/// backslash-escaped, which is enough to prevent the parser from matching the
1570/// pattern while the existing backslash-escape handler restores it on re-parse.
1571///
1572/// The character class for the name segment must match `try_parse_emoji_shortcode`
1573/// exactly (Unicode `is_alphanumeric` plus `_`, `+`, `-`).  An ASCII-only escape
1574/// would leave Unicode patterns like `:Café:` or `:ZBC::Zendesk::配置:` un-escaped
1575/// while still being detected as emoji on re-parse, splitting the text node
1576/// (issue #552).
1577fn escape_emoji_shortcodes(text: &str) -> String {
1578    let mut result = String::with_capacity(text.len());
1579
1580    for (i, ch) in text.char_indices() {
1581        if ch == ':' {
1582            // Check if this is a `:name:` pattern where name matches the
1583            // same character class accepted by `try_parse_emoji_shortcode`.
1584            let after = i + 1;
1585            if after < text.len() {
1586                let name_end = text[after..]
1587                    .find(|c: char| !c.is_alphanumeric() && c != '_' && c != '+' && c != '-')
1588                    .map_or(text[after..].len(), |pos| pos);
1589                if name_end > 0
1590                    && after + name_end < text.len()
1591                    && text.as_bytes()[after + name_end] == b':'
1592                {
1593                    // Found `:name:` pattern — escape the leading colon
1594                    result.push('\\');
1595                }
1596            }
1597        }
1598        result.push(ch);
1599    }
1600
1601    result
1602}
1603
1604/// Escapes a leading list-marker pattern on a line so it is not
1605/// re-parsed as a new list item.  `"2. text"` → `"2\. text"`,
1606/// `"- text"` → `"\- text"`.
1607fn escape_list_marker(line: &str) -> String {
1608    if let Some(dot_pos) = line.find(". ") {
1609        if parse_ordered_list_marker(line).is_some() {
1610            let mut s = String::with_capacity(line.len() + 1);
1611            s.push_str(&line[..dot_pos]);
1612            s.push('\\');
1613            s.push_str(&line[dot_pos..]);
1614            return s;
1615        }
1616    }
1617    for prefix in &["- ", "* ", "+ "] {
1618        if line.starts_with(prefix) {
1619            let mut s = String::with_capacity(line.len() + 1);
1620            s.push('\\');
1621            s.push_str(line);
1622            return s;
1623        }
1624    }
1625    line.to_string()
1626}
1627
1628/// Checks if a line is a valid fenced code block opener.
1629///
1630/// Per CommonMark: the opener is a sequence of three or more backticks
1631/// followed by an info string that must not contain any backtick
1632/// character, otherwise some inline code spans would be misinterpreted
1633/// as the beginning of a fenced code block.
1634fn is_code_fence_opener(line: &str) -> bool {
1635    if !line.starts_with("```") {
1636        return false;
1637    }
1638    !line[3..].contains('`')
1639}
1640
1641/// Checks if a line is a horizontal rule.
1642fn is_horizontal_rule(line: &str) -> bool {
1643    let trimmed = line.trim();
1644    trimmed.len() >= 3
1645        && ((trimmed.starts_with("---") && trimmed.chars().all(|c| c == '-'))
1646            || (trimmed.starts_with("***") && trimmed.chars().all(|c| c == '*'))
1647            || (trimmed.starts_with("___") && trimmed.chars().all(|c| c == '_')))
1648}
1649
1650/// Checks if a line is a GFM table separator (e.g., "|---|---|").
1651fn is_table_separator(line: &str) -> bool {
1652    let trimmed = line.trim();
1653    trimmed.contains('|')
1654        && trimmed
1655            .chars()
1656            .all(|c| c == '|' || c == '-' || c == ':' || c == ' ')
1657}
1658
1659/// Parses a GFM table row into cell contents.
1660///
1661/// Splits on unescaped `|` characters; a preceding backslash (`\|`) is
1662/// interpreted as a literal pipe and unescaped in the emitted cell content
1663/// (see issue #579).  This allows code spans and other inline content that
1664/// contain literal `|` to survive round-trip through a pipe table.
1665fn parse_table_row(line: &str) -> Vec<String> {
1666    let trimmed = line.trim();
1667    let trimmed = trimmed.strip_prefix('|').unwrap_or(trimmed);
1668    let trimmed = trimmed.strip_suffix('|').unwrap_or(trimmed);
1669
1670    let mut cells: Vec<String> = Vec::new();
1671    let mut current = String::new();
1672    let mut chars = trimmed.chars().peekable();
1673    while let Some(ch) = chars.next() {
1674        if ch == '\\' && chars.peek() == Some(&'|') {
1675            current.push('|');
1676            chars.next();
1677        } else if ch == '|' {
1678            cells.push(std::mem::take(&mut current));
1679        } else {
1680            current.push(ch);
1681        }
1682    }
1683    cells.push(current);
1684
1685    cells
1686        .iter()
1687        .map(|s| {
1688            // Strip exactly one leading and one trailing space (pipe table padding).
1689            // Preserve any additional whitespace as significant content.
1690            let stripped = s.strip_prefix(' ').unwrap_or(s.as_str());
1691            let stripped = stripped.strip_suffix(' ').unwrap_or(stripped);
1692            stripped.to_string()
1693        })
1694        .collect()
1695}
1696
1697/// Parses column alignments from a GFM table separator row.
1698/// Returns a vec of `Option<&str>` where `Some("center")` or `Some("end")` indicate alignment.
1699fn parse_table_alignments(separator_line: &str) -> Vec<Option<&'static str>> {
1700    let trimmed = separator_line.trim();
1701    let trimmed = trimmed.strip_prefix('|').unwrap_or(trimmed);
1702    let trimmed = trimmed.strip_suffix('|').unwrap_or(trimmed);
1703
1704    trimmed
1705        .split('|')
1706        .map(|cell| {
1707            let cell = cell.trim();
1708            let starts_colon = cell.starts_with(':');
1709            let ends_colon = cell.ends_with(':');
1710            match (starts_colon, ends_colon) {
1711                (true, true) => Some("center"),
1712                (false, true) => Some("end"),
1713                _ => None, // left/default
1714            }
1715        })
1716        .collect()
1717}
1718
1719/// Applies an alignment mark to a paragraph node if alignment is specified.
1720fn apply_column_alignment(para: &mut AdfNode, alignment: Option<&str>) {
1721    if let Some(align) = alignment {
1722        para.marks = Some(vec![AdfMark::alignment(align)]);
1723    }
1724}
1725
1726/// Extracts `{attrs}` prefix from a pipe table cell text.
1727/// Returns `(remaining_text, Option<adf_attrs_json>)`.
1728fn extract_cell_attrs(cell_text: &str) -> (String, Option<serde_json::Value>) {
1729    let trimmed = cell_text.trim_start();
1730    if !trimmed.starts_with('{') {
1731        return (cell_text.to_string(), None);
1732    }
1733    if let Some((end_pos, attrs)) = parse_attrs(trimmed, 0) {
1734        let remaining = trimmed[end_pos..].trim_start().to_string();
1735        let adf_attrs = build_cell_attrs(&attrs);
1736        (remaining, Some(adf_attrs))
1737    } else {
1738        (cell_text.to_string(), None)
1739    }
1740}
1741
1742/// Tries to parse a line as a block-level image and return a mediaSingle ADF node.
1743/// Used by both `try_image` (top-level blocks) and list item parsing.
1744fn try_parse_media_single_from_line(line: &str) -> Option<AdfNode> {
1745    let line = line.trim();
1746    if !line.starts_with("![") {
1747        return None;
1748    }
1749
1750    let (alt, url) = parse_image_syntax(line)?;
1751    let alt_opt = if alt.is_empty() { None } else { Some(alt) };
1752
1753    let paren_open = line.find("](")? + 1; // index of '('
1754    let img_end = find_closing_paren(line, paren_open)? + 1;
1755    let after_img = line[img_end..].trim_start();
1756
1757    if after_img.starts_with('{') {
1758        if let Some((_, attrs)) = parse_attrs(after_img, 0) {
1759            // Confluence file attachment — reconstruct type:file media node
1760            if attrs.get("type") == Some("file") || attrs.get("id").is_some() {
1761                let mut media_attrs = serde_json::json!({"type": "file"});
1762                if let Some(id) = attrs.get("id") {
1763                    media_attrs["id"] = serde_json::Value::String(id.to_string());
1764                }
1765                if let Some(collection) = attrs.get("collection") {
1766                    media_attrs["collection"] = serde_json::Value::String(collection.to_string());
1767                }
1768                if let Some(occurrence_key) = attrs.get("occurrenceKey") {
1769                    media_attrs["occurrenceKey"] =
1770                        serde_json::Value::String(occurrence_key.to_string());
1771                }
1772                if let Some(height) = attrs.get("height") {
1773                    if let Some(h) = parse_numeric_attr(height) {
1774                        media_attrs["height"] = h;
1775                    }
1776                }
1777                if let Some(width) = attrs.get("width") {
1778                    if let Some(w) = parse_numeric_attr(width) {
1779                        media_attrs["width"] = w;
1780                    }
1781                }
1782                if let Some(alt_text) = alt_opt {
1783                    media_attrs["alt"] = serde_json::Value::String(alt_text.to_string());
1784                }
1785                if let Some(local_id) = attrs.get("localId") {
1786                    media_attrs["localId"] = serde_json::Value::String(local_id.to_string());
1787                }
1788                let mut ms_attrs = serde_json::json!({"layout": "center"});
1789                if let Some(layout) = attrs.get("layout") {
1790                    ms_attrs["layout"] = serde_json::Value::String(layout.to_string());
1791                }
1792                if let Some(ms_width) = attrs.get("mediaWidth") {
1793                    if let Some(w) = parse_numeric_attr(ms_width) {
1794                        ms_attrs["width"] = w;
1795                    }
1796                }
1797                if let Some(wt) = attrs.get("widthType") {
1798                    ms_attrs["widthType"] = serde_json::Value::String(wt.to_string());
1799                }
1800                if let Some(mode) = attrs.get("mode") {
1801                    ms_attrs["mode"] = serde_json::Value::String(mode.to_string());
1802                }
1803                let border_marks = build_border_marks(&attrs);
1804                let media_marks = if border_marks.is_empty() {
1805                    None
1806                } else {
1807                    Some(border_marks)
1808                };
1809                return Some(AdfNode {
1810                    node_type: "mediaSingle".to_string(),
1811                    attrs: Some(ms_attrs),
1812                    content: Some(vec![AdfNode {
1813                        node_type: "media".to_string(),
1814                        attrs: Some(media_attrs),
1815                        content: None,
1816                        text: None,
1817                        marks: media_marks,
1818                        local_id: None,
1819                        parameters: None,
1820                    }]),
1821                    text: None,
1822                    marks: None,
1823                    local_id: None,
1824                    parameters: None,
1825                });
1826            }
1827
1828            // External image — apply layout/width/widthType to mediaSingle attrs
1829            let mut node = AdfNode::media_single(url, alt_opt);
1830            if let Some(ref mut node_attrs) = node.attrs {
1831                if let Some(layout) = attrs.get("layout") {
1832                    node_attrs["layout"] = serde_json::Value::String(layout.to_string());
1833                }
1834                if let Some(width) = attrs.get("width") {
1835                    if let Some(w) = parse_numeric_attr(width) {
1836                        node_attrs["width"] = w;
1837                    }
1838                }
1839                if let Some(wt) = attrs.get("widthType") {
1840                    node_attrs["widthType"] = serde_json::Value::String(wt.to_string());
1841                }
1842                if let Some(mode) = attrs.get("mode") {
1843                    node_attrs["mode"] = serde_json::Value::String(mode.to_string());
1844                }
1845            }
1846            if let Some(ref mut content) = node.content {
1847                if let Some(media) = content.first_mut() {
1848                    if let Some(local_id) = attrs.get("localId") {
1849                        if let Some(ref mut media_attrs) = media.attrs {
1850                            media_attrs["localId"] =
1851                                serde_json::Value::String(local_id.to_string());
1852                        }
1853                    }
1854                    let border_marks = build_border_marks(&attrs);
1855                    if !border_marks.is_empty() {
1856                        media.marks = Some(border_marks);
1857                    }
1858                }
1859            }
1860            return Some(node);
1861        }
1862    }
1863
1864    Some(AdfNode::media_single(url, alt_opt))
1865}
1866
1867/// Parses `![alt](url)` image syntax.
1868fn parse_image_syntax(line: &str) -> Option<(&str, &str)> {
1869    let line = line.trim();
1870    if !line.starts_with("![") {
1871        return None;
1872    }
1873
1874    let alt_end = line.find("](")?;
1875    let alt = &line[2..alt_end];
1876    let paren_start = alt_end + 1; // index of the '('
1877    let url_end = find_closing_paren(line, paren_start)?;
1878    let url = &line[paren_start + 1..url_end];
1879
1880    Some((alt, url))
1881}
1882
1883// ── Inline Parsing ──────────────────────────────────────────────────
1884
1885/// Parses inline markdown content into ADF inline nodes.
1886///
1887/// Detects bare URLs (e.g., `https://example.com`) and promotes them to
1888/// `inlineCard` nodes. Call this at the top level (paragraph, heading, cell,
1889/// list item) where a bare URL represents a smart link.
1890fn parse_inline(text: &str) -> Vec<AdfNode> {
1891    parse_inline_impl(text, true)
1892}
1893
1894/// Parses inline markdown content without promoting bare URLs to `inlineCard`.
1895///
1896/// Used when recursing into mark-wrapping constructs such as emphasis, strike,
1897/// bracketed spans, or links.  In those contexts, the enclosing syntax already
1898/// declares the semantic role of the content — a URL inside `[url]{underline}`
1899/// or `**url**` is the user's text, not a smart link (issue #553).
1900fn parse_inline_no_auto_cards(text: &str) -> Vec<AdfNode> {
1901    parse_inline_impl(text, false)
1902}
1903
1904/// Implementation backing [`parse_inline`] and [`parse_inline_no_auto_cards`].
1905///
1906/// When `auto_inline_card` is `false`, bare `http://`/`https://` URLs are
1907/// treated as plain text instead of being promoted to `inlineCard` nodes.
1908fn parse_inline_impl(text: &str, auto_inline_card: bool) -> Vec<AdfNode> {
1909    let mut nodes = Vec::new();
1910    let mut chars = text.char_indices().peekable();
1911    let mut plain_start = 0;
1912
1913    while let Some(&(i, ch)) = chars.peek() {
1914        match ch {
1915            '*' | '_' => {
1916                if let Some((end, content, is_bold)) = try_parse_emphasis(text, i) {
1917                    flush_plain(text, plain_start, i, &mut nodes);
1918                    let mark = if is_bold {
1919                        AdfMark::strong()
1920                    } else {
1921                        AdfMark::em()
1922                    };
1923                    let inner = parse_inline_no_auto_cards(content);
1924                    for mut node in inner {
1925                        prepend_mark(&mut node, mark.clone());
1926                        nodes.push(node);
1927                    }
1928                    // Advance past the consumed characters
1929                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
1930                        chars.next();
1931                    }
1932                    plain_start = end;
1933                    continue;
1934                }
1935                // For underscores, skip the entire delimiter run so that
1936                // individual `_` chars within a `__` or `___` run are not
1937                // re-tried as separate emphasis openers (CommonMark treats
1938                // consecutive underscores as a single delimiter run).
1939                if ch == '_' {
1940                    while chars.peek().is_some_and(|&(_, c)| c == '_') {
1941                        chars.next();
1942                    }
1943                } else {
1944                    chars.next();
1945                }
1946            }
1947            '~' => {
1948                if let Some((end, content)) = try_parse_strikethrough(text, i) {
1949                    flush_plain(text, plain_start, i, &mut nodes);
1950                    let inner = parse_inline_no_auto_cards(content);
1951                    for mut node in inner {
1952                        prepend_mark(&mut node, AdfMark::strike());
1953                        nodes.push(node);
1954                    }
1955                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
1956                        chars.next();
1957                    }
1958                    plain_start = end;
1959                    continue;
1960                }
1961                chars.next();
1962            }
1963            '`' => {
1964                if let Some((end, content)) = try_parse_inline_code(text, i) {
1965                    flush_plain(text, plain_start, i, &mut nodes);
1966                    nodes.push(AdfNode::text_with_marks(content, vec![AdfMark::code()]));
1967                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
1968                        chars.next();
1969                    }
1970                    plain_start = end;
1971                    continue;
1972                }
1973                // No code span starting here; skip past the entire backtick
1974                // run so a longer opening run isn't retried as a shorter one.
1975                while chars.peek().is_some_and(|&(_, c)| c == '`') {
1976                    chars.next();
1977                }
1978            }
1979            '[' => {
1980                if let Some((end, link_text, href)) = try_parse_link(text, i) {
1981                    flush_plain(text, plain_start, i, &mut nodes);
1982                    if link_text.starts_with("http://") || link_text.starts_with("https://") {
1983                        // URL-as-link-text: emit as text with link mark,
1984                        // not via parse_inline which would produce an inlineCard.
1985                        // Covers both exact matches and trailing-slash mismatches
1986                        // (issue #523).
1987                        nodes.push(AdfNode::text_with_marks(
1988                            link_text,
1989                            vec![AdfMark::link(href)],
1990                        ));
1991                    } else {
1992                        let inner = parse_inline_no_auto_cards(link_text);
1993                        for mut node in inner {
1994                            prepend_mark(&mut node, AdfMark::link(href));
1995                            nodes.push(node);
1996                        }
1997                    }
1998                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
1999                        chars.next();
2000                    }
2001                    plain_start = end;
2002                    continue;
2003                }
2004                // Try bracketed span with attributes: [text]{underline}
2005                if let Some((end, span_nodes)) = try_parse_bracketed_span(text, i) {
2006                    flush_plain(text, plain_start, i, &mut nodes);
2007                    nodes.extend(span_nodes);
2008                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2009                        chars.next();
2010                    }
2011                    plain_start = end;
2012                    continue;
2013                }
2014                chars.next();
2015            }
2016            ':' => {
2017                // Try generic inline directive (:card[url], :status[text]{attrs}, etc.)
2018                if let Some(node) = try_dispatch_inline_directive(text, i) {
2019                    flush_plain(text, plain_start, i, &mut nodes);
2020                    let end = node.1;
2021                    nodes.push(node.0);
2022                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2023                        chars.next();
2024                    }
2025                    plain_start = end;
2026                    continue;
2027                }
2028                // Try emoji shortcode :name: with optional {attrs}
2029                if let Some((end, short_name)) = try_parse_emoji_shortcode(text, i) {
2030                    flush_plain(text, plain_start, i, &mut nodes);
2031                    let (final_end, emoji_node) = parse_emoji_with_attrs(text, end, short_name);
2032                    nodes.push(emoji_node);
2033                    while chars.peek().is_some_and(|&(idx, _)| idx < final_end) {
2034                        chars.next();
2035                    }
2036                    plain_start = final_end;
2037                    continue;
2038                }
2039                chars.next();
2040            }
2041            ' ' if text[i..].starts_with("  \n") => {
2042                // Trailing-space line break → hardBreak node.
2043                // Flush preceding text (without the trailing spaces).
2044                flush_plain(text, plain_start, i, &mut nodes);
2045                nodes.push(AdfNode::hard_break());
2046                // Skip past all spaces and the newline
2047                while chars.peek().is_some_and(|&(_, c)| c == ' ') {
2048                    chars.next();
2049                }
2050                // Skip the newline
2051                if chars.peek().is_some_and(|&(_, c)| c == '\n') {
2052                    chars.next();
2053                }
2054                plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2055            }
2056            '!' if text[i..].starts_with("![") => {
2057                // Inline image — skip the ! and let [ handle it next iteration
2058                // (Images at block level are handled by try_image; inline images
2059                // degrade to link text in ADF since inline media is complex)
2060                chars.next();
2061            }
2062            'h' if auto_inline_card
2063                && (text[i..].starts_with("http://") || text[i..].starts_with("https://")) =>
2064            {
2065                if let Some((end, url)) = try_parse_bare_url(text, i) {
2066                    flush_plain(text, plain_start, i, &mut nodes);
2067                    nodes.push(AdfNode::inline_card(url));
2068                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2069                        chars.next();
2070                    }
2071                    plain_start = end;
2072                    continue;
2073                }
2074                chars.next();
2075            }
2076            '\\' if text.as_bytes().get(i + 1) == Some(&b'n')
2077                && text.as_bytes().get(i + 2) != Some(&b'\n') =>
2078            {
2079                // Issue #454: `\n` (backslash + letter n) encodes a literal
2080                // newline inside a text node. Emit the newline as a separate
2081                // text node so merge_adjacent_text can reassemble it.
2082                flush_plain(text, plain_start, i, &mut nodes);
2083                nodes.push(AdfNode::text("\n"));
2084                chars.next(); // consume the '\'
2085                chars.next(); // consume the 'n'
2086                plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2087            }
2088            '\\' if i + 1 < text.len() && !text[i..].starts_with("\\\n") => {
2089                // Backslash escape: skip the backslash and treat the next
2090                // character as literal text (e.g. `\\` → `\`,
2091                // `2\. text` → `2. text`, `\*word\*` → `*word*` without
2092                // emphasis, `\:fire:` → `:fire:` without emoji parsing).
2093                flush_plain(text, plain_start, i, &mut nodes);
2094                chars.next(); // consume the backslash
2095                              // Set plain_start to the escaped character so it is included
2096                              // in the next plain-text run, then advance past it so it is
2097                              // not re-interpreted as a special character (e.g. `*`, `_`, `:`).
2098                plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2099                chars.next(); // consume the escaped character
2100            }
2101            '\\' if text[i..].starts_with("\\\n") => {
2102                // Backslash line break → hardBreak node.
2103                flush_plain(text, plain_start, i, &mut nodes);
2104                nodes.push(AdfNode::hard_break());
2105                chars.next(); // consume the '\'
2106                              // Skip the newline
2107                if chars.peek().is_some_and(|&(_, c)| c == '\n') {
2108                    chars.next();
2109                }
2110                plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2111            }
2112            '\\' if i + 1 == text.len() => {
2113                // Trailing backslash at end of paragraph text → hardBreak node.
2114                flush_plain(text, plain_start, i, &mut nodes);
2115                nodes.push(AdfNode::hard_break());
2116                chars.next(); // consume the '\'
2117                plain_start = text.len();
2118            }
2119            _ => {
2120                chars.next();
2121            }
2122        }
2123    }
2124
2125    // Flush remaining plain text
2126    if plain_start < text.len() {
2127        let remaining = &text[plain_start..];
2128        if !remaining.is_empty() {
2129            nodes.push(AdfNode::text(remaining));
2130        }
2131    }
2132
2133    // Merge adjacent unmarked text nodes that can arise from backslash
2134    // escape handling (e.g. `"2"` + `". text"` → `"2. text"`).
2135    merge_adjacent_text(&mut nodes);
2136
2137    nodes
2138}
2139
2140/// Merges consecutive unmarked text nodes in-place.
2141fn merge_adjacent_text(nodes: &mut Vec<AdfNode>) {
2142    let mut i = 0;
2143    while i + 1 < nodes.len() {
2144        if nodes[i].node_type == "text"
2145            && nodes[i + 1].node_type == "text"
2146            && nodes[i].marks.is_none()
2147            && nodes[i + 1].marks.is_none()
2148        {
2149            let next_text = nodes[i + 1].text.clone().unwrap_or_default();
2150            if let Some(ref mut t) = nodes[i].text {
2151                t.push_str(&next_text);
2152            }
2153            nodes.remove(i + 1);
2154        } else {
2155            i += 1;
2156        }
2157    }
2158}
2159
2160/// Flushes accumulated plain text as a text node.
2161fn flush_plain(text: &str, start: usize, end: usize, nodes: &mut Vec<AdfNode>) {
2162    if start < end {
2163        let plain = &text[start..end];
2164        if !plain.is_empty() {
2165            nodes.push(AdfNode::text(plain));
2166        }
2167    }
2168}
2169
2170/// Adds a mark to a node (creates marks vec if needed).
2171#[cfg(test)]
2172fn add_mark(node: &mut AdfNode, mark: AdfMark) {
2173    if let Some(ref mut marks) = node.marks {
2174        marks.push(mark);
2175    } else {
2176        node.marks = Some(vec![mark]);
2177    }
2178}
2179
2180/// Prepends a mark before existing marks to preserve outside-in ordering.
2181fn prepend_mark(node: &mut AdfNode, mark: AdfMark) {
2182    if let Some(ref mut marks) = node.marks {
2183        marks.insert(0, mark);
2184    } else {
2185        node.marks = Some(vec![mark]);
2186    }
2187}
2188
2189/// Returns `true` when an underscore delimiter run of `len` bytes starting at
2190/// byte position `delim_pos` in `text` is flanked by alphanumeric characters on
2191/// **both** sides — meaning it sits inside a word and must NOT open or close an
2192/// emphasis span per CommonMark.
2193fn is_intraword_underscore(text: &str, delim_pos: usize, len: usize) -> bool {
2194    let before = text[..delim_pos]
2195        .chars()
2196        .next_back()
2197        .is_some_and(char::is_alphanumeric);
2198    let after = text[delim_pos + len..]
2199        .chars()
2200        .next()
2201        .is_some_and(char::is_alphanumeric);
2202    before && after
2203}
2204
2205/// Finds the first occurrence of `needle` in `haystack`, skipping over
2206/// backslash-escaped characters (e.g. `\*` is not matched when searching
2207/// for `*`).
2208fn find_unescaped(haystack: &str, needle: &str) -> Option<usize> {
2209    let needle_bytes = needle.as_bytes();
2210    let hay_bytes = haystack.as_bytes();
2211    let mut i = 0;
2212    while i < hay_bytes.len() {
2213        if hay_bytes[i] == b'\\' {
2214            i += 2; // skip escaped character
2215            continue;
2216        }
2217        if hay_bytes[i..].starts_with(needle_bytes) {
2218            return Some(i);
2219        }
2220        i += 1;
2221    }
2222    None
2223}
2224
2225/// Finds the first occurrence of a single byte `ch` in `haystack`, skipping
2226/// over backslash-escaped characters.
2227fn find_unescaped_char(haystack: &str, ch: u8) -> Option<usize> {
2228    let hay_bytes = haystack.as_bytes();
2229    let mut i = 0;
2230    while i < hay_bytes.len() {
2231        if hay_bytes[i] == b'\\' {
2232            i += 2;
2233            continue;
2234        }
2235        if hay_bytes[i] == ch {
2236            return Some(i);
2237        }
2238        i += 1;
2239    }
2240    None
2241}
2242
2243/// Tries to parse ***bold+italic***, **bold**, *italic* (or underscore variants) starting at position `i`.
2244/// Returns (end_position, inner_content, is_bold).
2245///
2246/// The triple-delimiter case (`***` / `___`) is checked first so that `***text***` is parsed as
2247/// bold wrapping italic content, rather than having the `**` branch consume the wrong closing
2248/// delimiter and leave stray `*` characters in the text (see issue #401).
2249///
2250/// For underscore delimiters, intraword positions are rejected per CommonMark: a `_` flanked
2251/// by alphanumeric characters on both sides must not open or close emphasis (see issue #438).
2252fn try_parse_emphasis(text: &str, i: usize) -> Option<(usize, &str, bool)> {
2253    let rest = &text[i..];
2254
2255    // Bold+italic: *** or ___
2256    // Parse as bold wrapping italic: the inner content will be recursively parsed and pick up
2257    // the inner * / _ as an em mark.
2258    if rest.starts_with("***") || rest.starts_with("___") {
2259        let is_underscore = rest.starts_with("___");
2260        if is_underscore && is_intraword_underscore(text, i, 3) {
2261            return None;
2262        }
2263        let triple = &rest[..3];
2264        let after = &rest[3..];
2265        if let Some(close) = find_unescaped(after, triple) {
2266            if close > 0 {
2267                let close_pos = i + 3 + close;
2268                if is_underscore && is_intraword_underscore(text, close_pos, 3) {
2269                    return None;
2270                }
2271                // Return a slice that includes the inner italic delimiters from the
2272                // original text: for `***text***`, return `*text*`.  The recursive
2273                // parse_inline call will then pick up the inner `*…*` as an em mark.
2274                let content = &rest[2..=3 + close];
2275                let end = i + 3 + close + 3;
2276                return Some((end, content, true));
2277            }
2278        }
2279    }
2280
2281    // Bold: ** or __
2282    if rest.starts_with("**") || rest.starts_with("__") {
2283        let is_underscore = rest.starts_with("__");
2284        if is_underscore && is_intraword_underscore(text, i, 2) {
2285            return None;
2286        }
2287        let delimiter = &rest[..2];
2288        let after = &rest[2..];
2289        let close = find_unescaped(after, delimiter)?;
2290        if close == 0 {
2291            return None;
2292        }
2293        let close_pos = i + 2 + close;
2294        if is_underscore && is_intraword_underscore(text, close_pos, 2) {
2295            return None;
2296        }
2297        let content = &after[..close];
2298        let end = i + 2 + close + 2;
2299        return Some((end, content, true));
2300    }
2301
2302    // Italic: * or _
2303    if rest.starts_with('*') || rest.starts_with('_') {
2304        let delim_char = rest.as_bytes()[0];
2305        let is_underscore = delim_char == b'_';
2306        if is_underscore && is_intraword_underscore(text, i, 1) {
2307            return None;
2308        }
2309        let after = &rest[1..];
2310        let close = find_unescaped_char(after, delim_char)?;
2311        if close == 0 {
2312            return None;
2313        }
2314        let close_pos = i + 1 + close;
2315        if is_underscore && is_intraword_underscore(text, close_pos, 1) {
2316            return None;
2317        }
2318        let content = &after[..close];
2319        let end = i + 1 + close + 1;
2320        return Some((end, content, false));
2321    }
2322
2323    None
2324}
2325
2326/// Tries to parse ~~strikethrough~~ starting at position `i`.
2327fn try_parse_strikethrough(text: &str, i: usize) -> Option<(usize, &str)> {
2328    let rest = &text[i..];
2329    if !rest.starts_with("~~") {
2330        return None;
2331    }
2332    let after = &rest[2..];
2333    let close = after.find("~~")?;
2334    if close == 0 {
2335        return None;
2336    }
2337    let content = &after[..close];
2338    Some((i + 2 + close + 2, content))
2339}
2340
2341/// Tries to parse a CommonMark inline code span starting at position `i`.
2342///
2343/// Supports multi-backtick delimiters: the opening run of N backticks must
2344/// be matched by a closing run of exactly N backticks.  If both ends of the
2345/// enclosed content begin and end with a space and the content is not all
2346/// spaces, one space is stripped from each side per the CommonMark spec.
2347fn try_parse_inline_code(text: &str, i: usize) -> Option<(usize, &str)> {
2348    let rest = &text[i..];
2349    let bytes = rest.as_bytes();
2350    if bytes.is_empty() || bytes[0] != b'`' {
2351        return None;
2352    }
2353    let mut opening = 0usize;
2354    while opening < bytes.len() && bytes[opening] == b'`' {
2355        opening += 1;
2356    }
2357
2358    let mut j = opening;
2359    while j < bytes.len() {
2360        if bytes[j] == b'`' {
2361            let run_start = j;
2362            while j < bytes.len() && bytes[j] == b'`' {
2363                j += 1;
2364            }
2365            if j - run_start == opening {
2366                let content = &rest[opening..run_start];
2367                let content = strip_code_span_padding(content);
2368                return Some((i + j, content));
2369            }
2370        } else {
2371            j += 1;
2372        }
2373    }
2374    None
2375}
2376
2377/// Implements the CommonMark code-span space-stripping rule: if the content
2378/// both begins and ends with a space character and is not composed entirely
2379/// of spaces, one space character is removed from each side.
2380fn strip_code_span_padding(content: &str) -> &str {
2381    let bytes = content.as_bytes();
2382    if bytes.len() >= 2
2383        && bytes[0] == b' '
2384        && bytes[bytes.len() - 1] == b' '
2385        && content.bytes().any(|b| b != b' ')
2386    {
2387        &content[1..content.len() - 1]
2388    } else {
2389        content
2390    }
2391}
2392
2393/// Tries to parse a bracketed span `[text]{attrs}` starting at position `i`.
2394/// Used for `[text]{underline}` and similar constructs.
2395fn try_parse_bracketed_span(text: &str, i: usize) -> Option<(usize, Vec<AdfNode>)> {
2396    let rest = &text[i..];
2397    if !rest.starts_with('[') {
2398        return None;
2399    }
2400
2401    // Find the matching ] by counting bracket depth (supports nested brackets
2402    // such as [[text](url)]{underline} for underline-before-link ordering).
2403    // Backslash-escaped brackets are skipped (issue #493).
2404    let mut depth: usize = 0;
2405    let mut bracket_close = None;
2406    let bs_bytes = rest.as_bytes();
2407    for (j, ch) in rest.char_indices() {
2408        match ch {
2409            '\\' if j + 1 < bs_bytes.len()
2410                && (bs_bytes[j + 1] == b'[' || bs_bytes[j + 1] == b']') => {}
2411            '[' if j == 0 || bs_bytes[j - 1] != b'\\' => depth += 1,
2412            ']' if j == 0 || bs_bytes[j - 1] != b'\\' => {
2413                depth -= 1;
2414                if depth == 0 {
2415                    bracket_close = Some(j);
2416                    break;
2417                }
2418            }
2419            _ => {}
2420        }
2421    }
2422    let bracket_close = bracket_close?;
2423    // Make sure this isn't a link: next char after ] must be { not (
2424    let after_bracket = &rest[bracket_close + 1..];
2425    if !after_bracket.starts_with('{') {
2426        return None;
2427    }
2428
2429    let span_text = &rest[1..bracket_close];
2430    let attrs_start = i + bracket_close + 1;
2431    let (attrs_end, attrs) = parse_attrs(text, attrs_start)?;
2432
2433    let mut marks = Vec::new();
2434    if attrs.has_flag("underline") {
2435        marks.push(AdfMark::underline());
2436    }
2437    let ann_ids = attrs.get_all("annotation-id");
2438    let ann_types = attrs.get_all("annotation-type");
2439    for (idx, ann_id) in ann_ids.iter().enumerate() {
2440        let ann_type = ann_types.get(idx).copied().unwrap_or("inlineComment");
2441        marks.push(AdfMark::annotation(ann_id, ann_type));
2442    }
2443
2444    if marks.is_empty() {
2445        return None; // no recognized marks
2446    }
2447
2448    let inner = parse_inline_no_auto_cards(span_text);
2449    let result: Vec<AdfNode> = inner
2450        .into_iter()
2451        .map(|mut node| {
2452            // Prepend bracket marks before inner marks to preserve original
2453            // ADF mark ordering (e.g., [underline, strong] not [strong, underline]).
2454            let mut combined = marks.clone();
2455            if let Some(ref existing) = node.marks {
2456                combined.extend(existing.iter().cloned());
2457            }
2458            node.marks = Some(combined);
2459            node
2460        })
2461        .collect();
2462
2463    Some((attrs_end, result))
2464}
2465
2466/// Dispatches an inline directive to the appropriate ADF node constructor.
2467/// Returns `(AdfNode, end_pos)` on success.
2468fn try_dispatch_inline_directive(text: &str, pos: usize) -> Option<(AdfNode, usize)> {
2469    let d = try_parse_inline_directive(text, pos)?;
2470    let content = d.content.as_deref().unwrap_or("");
2471
2472    let node = match d.name.as_str() {
2473        "card" => {
2474            // Prefer the `url` attribute when present; fall back to the
2475            // bracketed content.  The attribute form is used when the URL
2476            // contains characters (such as `]` or `\n`) that would otherwise
2477            // break `:card[URL]` parsing.
2478            let url = d
2479                .attrs
2480                .as_ref()
2481                .and_then(|a| a.get("url"))
2482                .unwrap_or(content);
2483            let mut node = AdfNode::inline_card(url);
2484            pass_through_local_id(&d.attrs, &mut node);
2485            node
2486        }
2487        "status" => {
2488            let color = d
2489                .attrs
2490                .as_ref()
2491                .and_then(|a| a.get("color"))
2492                .unwrap_or("neutral");
2493            let mut node = AdfNode::status(content, color);
2494            // Pass through style and localId if present
2495            if let Some(ref attrs) = d.attrs {
2496                if let Some(ref mut node_attrs) = node.attrs {
2497                    if let Some(style) = attrs.get("style") {
2498                        node_attrs["style"] = serde_json::Value::String(style.to_string());
2499                    }
2500                    if let Some(local_id) = attrs.get("localId") {
2501                        node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
2502                    }
2503                }
2504            }
2505            node
2506        }
2507        "date" => {
2508            let timestamp = d
2509                .attrs
2510                .as_ref()
2511                .and_then(|a| a.get("timestamp"))
2512                .map_or_else(|| iso_date_to_epoch_ms(content), ToString::to_string);
2513            let mut node = AdfNode::date(&timestamp);
2514            pass_through_local_id(&d.attrs, &mut node);
2515            node
2516        }
2517        "mention" => {
2518            let id = d.attrs.as_ref().and_then(|a| a.get("id")).unwrap_or("");
2519            let mut node = AdfNode::mention(id, content);
2520            // Pass through optional userType and accessLevel
2521            if let Some(ref attrs) = d.attrs {
2522                if let (Some(ref mut node_attrs), true) = (
2523                    &mut node.attrs,
2524                    attrs.get("userType").is_some() || attrs.get("accessLevel").is_some(),
2525                ) {
2526                    if let Some(ut) = attrs.get("userType") {
2527                        node_attrs["userType"] = serde_json::Value::String(ut.to_string());
2528                    }
2529                    if let Some(al) = attrs.get("accessLevel") {
2530                        node_attrs["accessLevel"] = serde_json::Value::String(al.to_string());
2531                    }
2532                }
2533            }
2534            pass_through_local_id(&d.attrs, &mut node);
2535            node
2536        }
2537        "span" => {
2538            let mut marks = Vec::new();
2539            if let Some(ref attrs) = d.attrs {
2540                if let Some(color) = attrs.get("color") {
2541                    marks.push(AdfMark::text_color(color));
2542                }
2543                if let Some(bg) = attrs.get("bg") {
2544                    marks.push(AdfMark::background_color(bg));
2545                }
2546                if attrs.has_flag("sub") {
2547                    marks.push(AdfMark::subsup("sub"));
2548                }
2549                if attrs.has_flag("sup") {
2550                    marks.push(AdfMark::subsup("sup"));
2551                }
2552            }
2553            if marks.is_empty() {
2554                AdfNode::text(content)
2555            } else {
2556                // Parse inner content to handle nested syntax (e.g., links).
2557                // Prepend span marks before inner marks to preserve ordering.
2558                let inner = parse_inline_no_auto_cards(content);
2559                let mut nodes: Vec<AdfNode> = inner
2560                    .into_iter()
2561                    .map(|mut node| {
2562                        let mut combined = marks.clone();
2563                        if let Some(ref existing) = node.marks {
2564                            combined.extend(existing.iter().cloned());
2565                        }
2566                        node.marks = Some(combined);
2567                        node
2568                    })
2569                    .collect();
2570                // Return the first marked node (typical case is a single node).
2571                nodes.remove(0)
2572            }
2573        }
2574        "placeholder" => AdfNode::placeholder(content),
2575        "media-inline" => {
2576            let mut json_attrs = serde_json::Map::new();
2577            if let Some(ref attrs) = d.attrs {
2578                for key in &["type", "id", "collection", "url", "alt", "width", "height"] {
2579                    if let Some(val) = attrs.get(key) {
2580                        if *key == "width" || *key == "height" {
2581                            if let Ok(n) = val.parse::<u64>() {
2582                                json_attrs.insert(
2583                                    (*key).to_string(),
2584                                    serde_json::Value::Number(n.into()),
2585                                );
2586                                continue;
2587                            }
2588                        }
2589                        json_attrs.insert(
2590                            (*key).to_string(),
2591                            serde_json::Value::String(val.to_string()),
2592                        );
2593                    }
2594                }
2595                if let Some(local_id) = attrs.get("localId") {
2596                    json_attrs.insert(
2597                        "localId".to_string(),
2598                        serde_json::Value::String(local_id.to_string()),
2599                    );
2600                }
2601            }
2602            AdfNode::media_inline(serde_json::Value::Object(json_attrs))
2603        }
2604        "extension" => {
2605            let ext_type = d.attrs.as_ref().and_then(|a| a.get("type")).unwrap_or("");
2606            let ext_key = d.attrs.as_ref().and_then(|a| a.get("key")).unwrap_or("");
2607            AdfNode::inline_extension(ext_type, ext_key, Some(content))
2608        }
2609        _ => return None, // unknown directive — fall through to plain text
2610    };
2611
2612    Some((node, d.end_pos))
2613}
2614
2615/// Tries to parse a bare URL (`http://` or `https://`) starting at position `i`.
2616/// Scans forward until whitespace, `)`, `]`, or end of string.
2617fn try_parse_bare_url(text: &str, i: usize) -> Option<(usize, &str)> {
2618    let rest = &text[i..];
2619    if !rest.starts_with("http://") && !rest.starts_with("https://") {
2620        return None;
2621    }
2622    // URL extends to the next whitespace or delimiter
2623    let end = rest
2624        .find(|c: char| c.is_whitespace() || c == ')' || c == ']' || c == '>')
2625        .unwrap_or(rest.len());
2626    // Strip trailing punctuation that's likely not part of the URL
2627    let url = rest[..end].trim_end_matches(['.', ',', ';', '!', '?']);
2628    if url.len() <= "https://".len() {
2629        return None; // too short to be a real URL
2630    }
2631    Some((i + url.len(), url))
2632}
2633
2634/// Tries to parse an emoji shortcode `:name:` starting at position `i`.
2635/// The name must match `[a-zA-Z0-9_+-]+`.
2636fn try_parse_emoji_shortcode(text: &str, i: usize) -> Option<(usize, &str)> {
2637    let rest = &text[i..];
2638    if !rest.starts_with(':') {
2639        return None;
2640    }
2641    let after = &rest[1..];
2642    let name_end =
2643        after.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '+' && c != '-')?;
2644    if name_end == 0 {
2645        return None;
2646    }
2647    if after.as_bytes().get(name_end) != Some(&b':') {
2648        return None;
2649    }
2650    let name = &after[..name_end];
2651    Some((i + 1 + name_end + 1, name))
2652}
2653
2654/// Parses an emoji shortcode that has already been matched, then checks for
2655/// trailing `{id="..." text="..."}` attributes to preserve round-trip fidelity.
2656fn parse_emoji_with_attrs(text: &str, shortcode_end: usize, short_name: &str) -> (usize, AdfNode) {
2657    // Issue #576: An emoji with a combined shortName like `:slightly_smiling_face::bow:`
2658    // is emitted as `:slightly_smiling_face::bow:{shortName="..." ...}`. Extend the
2659    // match through any adjacent `:name:` shortcodes so that a trailing directive
2660    // attaches to the whole chain as a single emoji, using the directive's shortName.
2661    let mut chain_end = shortcode_end;
2662    while let Some((next_end, _)) = try_parse_emoji_shortcode(text, chain_end) {
2663        chain_end = next_end;
2664    }
2665    if chain_end > shortcode_end {
2666        if let Some((attr_end, attrs)) = parse_attrs(text, chain_end) {
2667            return (attr_end, build_emoji_node(&attrs, short_name));
2668        }
2669    }
2670
2671    if let Some((attr_end, attrs)) = parse_attrs(text, shortcode_end) {
2672        (attr_end, build_emoji_node(&attrs, short_name))
2673    } else {
2674        (shortcode_end, AdfNode::emoji(&format!(":{short_name}:")))
2675    }
2676}
2677
2678/// Builds an emoji `AdfNode` from parsed directive attrs, falling back to
2679/// the matched shortcode name when `shortName` is absent from the directive.
2680fn build_emoji_node(attrs: &Attrs, short_name: &str) -> AdfNode {
2681    let resolved_name = attrs
2682        .get("shortName")
2683        .map_or_else(|| format!(":{short_name}:"), str::to_string);
2684    let mut emoji_attrs = serde_json::json!({ "shortName": resolved_name });
2685    if let Some(id) = attrs.get("id") {
2686        emoji_attrs["id"] = serde_json::Value::String(id.to_string());
2687    }
2688    if let Some(t) = attrs.get("text") {
2689        emoji_attrs["text"] = serde_json::Value::String(t.to_string());
2690    }
2691    if let Some(lid) = attrs.get("localId") {
2692        emoji_attrs["localId"] = serde_json::Value::String(lid.to_string());
2693    }
2694    AdfNode {
2695        node_type: "emoji".to_string(),
2696        attrs: Some(emoji_attrs),
2697        content: None,
2698        text: None,
2699        marks: None,
2700        local_id: None,
2701        parameters: None,
2702    }
2703}
2704
2705/// Finds the closing `)` that matches the opening `(` at position `open`,
2706/// counting nested parentheses so that URLs containing `(` and `)` are
2707/// handled correctly.  Returns the index of the matching `)` relative to
2708/// the start of `s`, or `None` if no match is found.
2709fn find_closing_paren(s: &str, open: usize) -> Option<usize> {
2710    let mut depth: usize = 0;
2711    for (j, ch) in s[open..].char_indices() {
2712        match ch {
2713            '(' => depth += 1,
2714            ')' => {
2715                depth -= 1;
2716                if depth == 0 {
2717                    return Some(open + j);
2718                }
2719            }
2720            _ => {}
2721        }
2722    }
2723    None
2724}
2725
2726/// Tries to parse [text](url) starting at position `i`.
2727///
2728/// Uses bracket depth counting to find the matching `]`, so that `[` characters
2729/// inside the text (e.g. `[Task] some text ([Link](url))`) don't cause a false
2730/// match on an earlier `](`.
2731fn try_parse_link(text: &str, i: usize) -> Option<(usize, &str, &str)> {
2732    let rest = &text[i..];
2733    if !rest.starts_with('[') {
2734        return None;
2735    }
2736
2737    // Find the matching ] by counting bracket depth, skipping escaped brackets
2738    let mut depth: usize = 0;
2739    let mut text_end = None;
2740    let bytes = rest.as_bytes();
2741    for (j, ch) in rest.char_indices() {
2742        match ch {
2743            '\\' if j + 1 < bytes.len() && (bytes[j + 1] == b'[' || bytes[j + 1] == b']') => {
2744                // Skip backslash-escaped bracket (issue #493)
2745            }
2746            '[' if j == 0 || bytes[j - 1] != b'\\' => depth += 1,
2747            ']' if j == 0 || bytes[j - 1] != b'\\' => {
2748                depth -= 1;
2749                if depth == 0 {
2750                    text_end = Some(j);
2751                    break;
2752                }
2753            }
2754            _ => {}
2755        }
2756    }
2757
2758    let text_end = text_end?;
2759    let link_text = &rest[1..text_end];
2760    // Must be immediately followed by (
2761    let after_bracket = &rest[text_end + 1..];
2762    if !after_bracket.starts_with('(') {
2763        return None;
2764    }
2765    let url_start = text_end + 1; // index of the '('
2766    let url_end = find_closing_paren(rest, url_start)?;
2767    let href = &rest[url_start + 1..url_end];
2768
2769    Some((i + url_end + 1, link_text, href))
2770}
2771
2772// ── ADF → Markdown ──────────────────────────────────────────────────
2773
2774/// Options for ADF-to-markdown rendering.
2775#[derive(Debug, Clone, Default)]
2776pub struct RenderOptions {
2777    /// When true, omit `localId` attributes from directive output.
2778    pub strip_local_ids: bool,
2779}
2780
2781/// Converts an ADF document to a markdown string.
2782pub fn adf_to_markdown(doc: &AdfDocument) -> Result<String> {
2783    adf_to_markdown_with_options(doc, &RenderOptions::default())
2784}
2785
2786/// Converts an ADF document to a markdown string with options.
2787pub fn adf_to_markdown_with_options(doc: &AdfDocument, opts: &RenderOptions) -> Result<String> {
2788    let mut output = String::new();
2789
2790    for (i, node) in doc.content.iter().enumerate() {
2791        if i > 0 {
2792            output.push('\n');
2793        }
2794        render_block_node(node, &mut output, opts);
2795    }
2796
2797    Ok(output)
2798}
2799
2800/// Pushes a `localId=<value>` entry to an attribute parts vec,
2801/// unless `opts.strip_local_ids` is set or the value is a placeholder.
2802/// Copies `localId` from parsed directive attrs to an ADF node's attrs if present.
2803fn pass_through_local_id(dir_attrs: &Option<crate::atlassian::attrs::Attrs>, node: &mut AdfNode) {
2804    if let Some(ref attrs) = dir_attrs {
2805        if let Some(local_id) = attrs.get("localId") {
2806            if let Some(ref mut node_attrs) = node.attrs {
2807                node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
2808            } else {
2809                node.attrs = Some(serde_json::json!({"localId": local_id}));
2810            }
2811        }
2812    }
2813}
2814
2815/// Copies `localId` from directive attrs to the node's top-level `local_id` field,
2816/// and parses `params` JSON from directive attrs into the node's `parameters` field.
2817fn pass_through_expand_params(
2818    dir_attrs: &Option<crate::atlassian::attrs::Attrs>,
2819    node: &mut AdfNode,
2820) {
2821    if let Some(ref attrs) = dir_attrs {
2822        if let Some(local_id) = attrs.get("localId") {
2823            node.local_id = Some(local_id.to_string());
2824        }
2825        if let Some(params_str) = attrs.get("params") {
2826            if let Ok(params) = serde_json::from_str(params_str) {
2827                node.parameters = Some(params);
2828            }
2829        }
2830    }
2831}
2832
2833// listItem localId is emitted as trailing inline attrs on the item line
2834// (e.g., `- item text {localId=...}`) and parsed back by extracting
2835// trailing attrs from the list item text. This avoids the block-attrs
2836// promotion issue where {localId=...} on a separate line would be
2837// applied to the parent list node.
2838
2839/// Extracts trailing `{localId=... paraLocalId=...}` from list item text.
2840/// Returns the text without the trailing attrs, the listItem localId, and
2841/// the paragraph localId if found.
2842fn extract_trailing_local_id(text: &str) -> (&str, Option<String>, Option<String>) {
2843    let trimmed = text.trim_end();
2844    if !trimmed.ends_with('}') {
2845        return (text, None, None);
2846    }
2847    // Find the opening brace.  Only match a standalone `{…}` block that is
2848    // preceded by whitespace (or is at the start of the string).  A `{` that
2849    // immediately follows `]` is part of an inline directive (e.g.
2850    // `:mention[text]{id=… localId=…}`) and must NOT be consumed here.
2851    if let Some(brace_pos) = trimmed.rfind('{') {
2852        if brace_pos > 0 && !trimmed.as_bytes()[brace_pos - 1].is_ascii_whitespace() {
2853            return (text, None, None);
2854        }
2855        let attr_str = &trimmed[brace_pos..];
2856        if let Some((_, attrs)) = parse_attrs(attr_str, 0) {
2857            let local_id = attrs.get("localId").map(str::to_string);
2858            let para_local_id = attrs.get("paraLocalId").map(str::to_string);
2859            if local_id.is_some() || para_local_id.is_some() {
2860                let before = trimmed[..brace_pos]
2861                    .strip_suffix(' ')
2862                    .unwrap_or(&trimmed[..brace_pos]);
2863                return (before, local_id, para_local_id);
2864            }
2865        }
2866    }
2867    (text, None, None)
2868}
2869
2870/// Creates a `listItem` node, optionally with a `localId` attribute
2871/// and a `paraLocalId` on its first paragraph child.
2872/// Parses the first line of a list item and any indented sub-content into
2873/// an `AdfNode::list_item`.  When the first line is a code fence opener
2874/// (`` ``` ``), the line is folded into the sub-content so the block-level
2875/// code fence parser handles it correctly (issue #511).
2876fn parse_list_item_first_line(
2877    item_text: &str,
2878    sub_lines: Vec<String>,
2879    local_id: Option<String>,
2880    para_local_id: Option<String>,
2881) -> Result<AdfNode> {
2882    if item_text.starts_with("```") {
2883        // Treat the code fence opener + indented body as block content.
2884        let mut all_lines = vec![item_text.to_string()];
2885        all_lines.extend(sub_lines);
2886        let combined = all_lines.join("\n");
2887        let nested = MarkdownParser::new(&combined).parse_blocks()?;
2888        Ok(list_item_with_local_id(nested, local_id, para_local_id))
2889    } else if let Some(media) = try_parse_media_single_from_line(item_text) {
2890        // Block-level image (issue #430).
2891        if sub_lines.is_empty() {
2892            Ok(list_item_with_local_id(
2893                vec![media],
2894                local_id,
2895                para_local_id,
2896            ))
2897        } else {
2898            let sub_text = sub_lines.join("\n");
2899            let mut nested = MarkdownParser::new(&sub_text).parse_blocks()?;
2900            let mut content = vec![media];
2901            content.append(&mut nested);
2902            Ok(list_item_with_local_id(content, local_id, para_local_id))
2903        }
2904    } else {
2905        let first_node = AdfNode::paragraph(parse_inline(item_text));
2906        if sub_lines.is_empty() {
2907            Ok(list_item_with_local_id(
2908                vec![first_node],
2909                local_id,
2910                para_local_id,
2911            ))
2912        } else {
2913            let sub_text = sub_lines.join("\n");
2914            let mut nested = MarkdownParser::new(&sub_text).parse_blocks()?;
2915            let mut content = vec![first_node];
2916            content.append(&mut nested);
2917            Ok(list_item_with_local_id(content, local_id, para_local_id))
2918        }
2919    }
2920}
2921
2922fn list_item_with_local_id(
2923    mut content: Vec<AdfNode>,
2924    local_id: Option<String>,
2925    para_local_id: Option<String>,
2926) -> AdfNode {
2927    if let Some(id) = &para_local_id {
2928        if let Some(first) = content.first_mut() {
2929            if first.node_type == "paragraph" {
2930                let node_attrs = first.attrs.get_or_insert_with(|| serde_json::json!({}));
2931                node_attrs["localId"] = serde_json::Value::String(id.clone());
2932            }
2933        }
2934    }
2935    let mut item = AdfNode::list_item(content);
2936    if let Some(id) = local_id {
2937        item.attrs = Some(serde_json::json!({"localId": id}));
2938    }
2939    item
2940}
2941
2942fn maybe_push_local_id(attrs: &serde_json::Value, parts: &mut Vec<String>, opts: &RenderOptions) {
2943    if opts.strip_local_ids {
2944        return;
2945    }
2946    if let Some(local_id) = attrs.get("localId").and_then(serde_json::Value::as_str) {
2947        if !local_id.is_empty() && local_id != "00000000-0000-0000-0000-000000000000" {
2948            parts.push(format_kv("localId", local_id));
2949        }
2950    }
2951}
2952
2953/// Renders a sequence of block nodes with blank-line separators between them.
2954fn render_block_children(children: &[AdfNode], output: &mut String, opts: &RenderOptions) {
2955    for (i, child) in children.iter().enumerate() {
2956        if i > 0 {
2957            output.push('\n');
2958        }
2959        render_block_node(child, output, opts);
2960    }
2961}
2962
2963/// Formats a float as an integer string when it has no fractional part,
2964/// otherwise as a regular float string.
2965fn fmt_f64_attr(v: f64) -> String {
2966    if v.fract() == 0.0 {
2967        format!("{}", v as i64)
2968    } else {
2969        v.to_string()
2970    }
2971}
2972
2973/// Parses a numeric attribute value string into a JSON number value that
2974/// preserves the original integer/float distinction. Returns `None` if the
2975/// string cannot be parsed as a number.
2976///
2977/// Strings without a `.` or exponent are parsed as integers (so `"800"` stays
2978/// `800`, not `800.0`); strings with a decimal point are parsed as floats.
2979fn parse_numeric_attr(s: &str) -> Option<serde_json::Value> {
2980    if s.contains('.') || s.contains('e') || s.contains('E') {
2981        s.parse::<f64>().ok().map(serde_json::Value::from)
2982    } else {
2983        s.parse::<i64>().ok().map(serde_json::Value::from)
2984    }
2985}
2986
2987/// Formats a JSON numeric value as a markdown attribute string, preserving
2988/// whether the source was stored as an integer or a float.
2989///
2990/// Returns `None` if `v` is not a number. Integer values emit as `800`;
2991/// floating-point values emit as `800.0` (or `66.66` for non-integer floats),
2992/// so that a subsequent [`parse_numeric_attr`] round-trip recovers the same
2993/// JSON type.
2994fn fmt_numeric_attr(v: &serde_json::Value) -> Option<String> {
2995    if let Some(n) = v.as_i64() {
2996        return Some(n.to_string());
2997    }
2998    if let Some(n) = v.as_u64() {
2999        return Some(n.to_string());
3000    }
3001    if let Some(n) = v.as_f64() {
3002        if n.fract() == 0.0 && n.is_finite() {
3003            return Some(format!("{n:.1}"));
3004        }
3005        return Some(n.to_string());
3006    }
3007    None
3008}
3009
3010/// Renders a block-level ADF node to markdown.
3011fn render_block_node(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
3012    match node.node_type.as_str() {
3013        "paragraph" => {
3014            let is_empty = node.content.as_ref().map_or(true, Vec::is_empty);
3015            // Build directive attr string for localId when using ::paragraph form
3016            let dir_attrs = {
3017                let mut parts = Vec::new();
3018                if let Some(ref attrs) = node.attrs {
3019                    maybe_push_local_id(attrs, &mut parts, opts);
3020                }
3021                if parts.is_empty() {
3022                    String::new()
3023                } else {
3024                    format!("{{{}}}", parts.join(" "))
3025                }
3026            };
3027            if is_empty {
3028                output.push_str(&format!("::paragraph{dir_attrs}\n"));
3029            } else {
3030                // Render to a buffer first to check if content is whitespace-only
3031                let mut buf = String::new();
3032                render_inline_content(node, &mut buf, opts);
3033                if buf.trim().is_empty() && !buf.is_empty() {
3034                    // Whitespace-only content (e.g. NBSP) would be lost as a plain
3035                    // line — use the ::paragraph[content]{attrs} directive form
3036                    output.push_str(&format!("::paragraph[{buf}]{dir_attrs}\n"));
3037                } else {
3038                    // Escape a leading list-marker pattern so paragraph
3039                    // text is not re-parsed as a list item (issue #402).
3040                    // Indent continuation lines produced by hardBreaks so
3041                    // they are not re-parsed as list items (issue #455).
3042                    let mut is_first_line = true;
3043                    for line in buf.split('\n') {
3044                        if is_first_line {
3045                            if is_list_start(line) {
3046                                output.push_str(&escape_list_marker(line));
3047                            } else {
3048                                output.push_str(line);
3049                            }
3050                            is_first_line = false;
3051                        } else {
3052                            output.push('\n');
3053                            if !line.is_empty() {
3054                                output.push_str("  ");
3055                            }
3056                            output.push_str(line);
3057                        }
3058                    }
3059                    output.push('\n');
3060                }
3061            }
3062        }
3063        "heading" => {
3064            let level = node
3065                .attrs
3066                .as_ref()
3067                .and_then(|a| a.get("level"))
3068                .and_then(serde_json::Value::as_u64)
3069                .unwrap_or(1);
3070            for _ in 0..level {
3071                output.push('#');
3072            }
3073            output.push(' ');
3074            let mut buf = String::new();
3075            render_inline_content(node, &mut buf, opts);
3076            // Indent continuation lines produced by hardBreaks so they stay
3077            // within the heading when re-parsed (issue #433).
3078            let mut is_first_line = true;
3079            for line in buf.split('\n') {
3080                if is_first_line {
3081                    output.push_str(line);
3082                    is_first_line = false;
3083                } else {
3084                    output.push('\n');
3085                    if !line.is_empty() {
3086                        output.push_str("  ");
3087                    }
3088                    output.push_str(line);
3089                }
3090            }
3091            output.push('\n');
3092        }
3093        "codeBlock" => {
3094            let language_value = node.attrs.as_ref().and_then(|a| a.get("language"));
3095            let language = language_value
3096                .and_then(serde_json::Value::as_str)
3097                .unwrap_or("");
3098            output.push_str("```");
3099            if language.is_empty() && language_value.is_some() {
3100                // Explicit empty language attr: encode as ```"" to distinguish
3101                // from a codeBlock with no attrs at all (plain ```).
3102                output.push_str("\"\"");
3103            } else {
3104                output.push_str(language);
3105            }
3106            output.push('\n');
3107            if let Some(ref content) = node.content {
3108                for child in content {
3109                    if let Some(ref text) = child.text {
3110                        output.push_str(text);
3111                    }
3112                }
3113            }
3114            output.push_str("\n```\n");
3115        }
3116        "blockquote" => {
3117            if let Some(ref content) = node.content {
3118                for (i, child) in content.iter().enumerate() {
3119                    // Separate consecutive paragraph siblings with a blank
3120                    // blockquote-prefixed line so they re-parse as distinct
3121                    // paragraphs rather than being merged into one (issue #531).
3122                    if i > 0
3123                        && child.node_type == "paragraph"
3124                        && content[i - 1].node_type == "paragraph"
3125                    {
3126                        output.push_str(">\n");
3127                    }
3128                    let mut inner = String::new();
3129                    render_block_node(child, &mut inner, opts);
3130                    for line in inner.lines() {
3131                        output.push_str("> ");
3132                        output.push_str(line);
3133                        output.push('\n');
3134                    }
3135                }
3136            }
3137        }
3138        "bulletList" => {
3139            if let Some(ref items) = node.content {
3140                for item in items {
3141                    output.push_str("- ");
3142                    let content_start = output.len();
3143                    render_list_item_content(item, output, opts);
3144                    // If the rendered content begins with a sequence the
3145                    // bullet-list parser would interpret as a task checkbox
3146                    // marker, escape the leading `[` so the round-trip
3147                    // preserves this as a `bulletList` rather than promoting
3148                    // it to a `taskList` (issue #548).
3149                    if starts_with_task_marker(&output[content_start..]) {
3150                        output.insert(content_start, '\\');
3151                    }
3152                }
3153            }
3154        }
3155        "orderedList" => {
3156            let start = node
3157                .attrs
3158                .as_ref()
3159                .and_then(|a| a.get("order"))
3160                .and_then(serde_json::Value::as_u64)
3161                .unwrap_or(1);
3162            if let Some(ref items) = node.content {
3163                for (i, item) in items.iter().enumerate() {
3164                    let num = start + i as u64;
3165                    output.push_str(&format!("{num}. "));
3166                    render_list_item_content(item, output, opts);
3167                }
3168            }
3169        }
3170        "taskList" => {
3171            if let Some(ref items) = node.content {
3172                for item in items {
3173                    if item.node_type == "taskList" {
3174                        // A nested taskList is a sibling child of the outer
3175                        // taskList — render it indented so it round-trips back
3176                        // as a taskList, not a taskItem (issue #506).
3177                        let mut nested = String::new();
3178                        render_block_node(item, &mut nested, opts);
3179                        for line in nested.lines() {
3180                            output.push_str("  ");
3181                            output.push_str(line);
3182                            output.push('\n');
3183                        }
3184                    } else {
3185                        let state = item
3186                            .attrs
3187                            .as_ref()
3188                            .and_then(|a| a.get("state"))
3189                            .and_then(serde_json::Value::as_str)
3190                            .unwrap_or("TODO");
3191                        if state == "DONE" {
3192                            output.push_str("- [x] ");
3193                        } else {
3194                            output.push_str("- [ ] ");
3195                        }
3196                        render_list_item_content(item, output, opts);
3197                    }
3198                }
3199            }
3200        }
3201        "rule" => {
3202            output.push_str("---\n");
3203        }
3204        "table" => {
3205            render_table(node, output, opts);
3206        }
3207        "mediaSingle" => {
3208            if let Some(ref content) = node.content {
3209                for child in content {
3210                    if child.node_type == "media" {
3211                        render_media(child, node.attrs.as_ref(), output, opts);
3212                    }
3213                }
3214                for child in content {
3215                    if child.node_type == "caption" {
3216                        let mut cap_parts = Vec::new();
3217                        if let Some(ref attrs) = child.attrs {
3218                            maybe_push_local_id(attrs, &mut cap_parts, opts);
3219                        }
3220                        if cap_parts.is_empty() {
3221                            output.push_str(":::caption\n");
3222                        } else {
3223                            output.push_str(&format!(":::caption{{{}}}\n", cap_parts.join(" ")));
3224                        }
3225                        if let Some(ref caption_content) = child.content {
3226                            for inline in caption_content {
3227                                render_inline_node(inline, output, opts);
3228                            }
3229                            output.push('\n');
3230                        }
3231                        output.push_str(":::\n");
3232                    }
3233                }
3234            }
3235        }
3236        "blockCard" => {
3237            if let Some(ref attrs) = node.attrs {
3238                let url = attrs
3239                    .get("url")
3240                    .and_then(serde_json::Value::as_str)
3241                    .unwrap_or("");
3242                let mut attr_parts = Vec::new();
3243                if url_safe_in_bracket_content(url) {
3244                    output.push_str(&format!("::card[{url}]"));
3245                } else {
3246                    // URL would break `::card[URL]` parsing; use quoted attr form.
3247                    output.push_str("::card[]");
3248                    let escaped = url.replace('\\', "\\\\").replace('"', "\\\"");
3249                    attr_parts.push(format!("url=\"{escaped}\""));
3250                }
3251                if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3252                    attr_parts.push(format!("layout={layout}"));
3253                }
3254                if let Some(width) = attrs.get("width").and_then(serde_json::Value::as_u64) {
3255                    attr_parts.push(format!("width={width}"));
3256                }
3257                if !attr_parts.is_empty() {
3258                    output.push_str(&format!("{{{}}}", attr_parts.join(" ")));
3259                }
3260                output.push('\n');
3261            }
3262        }
3263        "embedCard" => {
3264            if let Some(ref attrs) = node.attrs {
3265                let url = attrs
3266                    .get("url")
3267                    .and_then(serde_json::Value::as_str)
3268                    .unwrap_or("");
3269                output.push_str(&format!("::embed[{url}]"));
3270                let mut attr_parts = Vec::new();
3271                if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3272                    attr_parts.push(format!("layout={layout}"));
3273                }
3274                if let Some(h) = attrs
3275                    .get("originalHeight")
3276                    .and_then(serde_json::Value::as_f64)
3277                {
3278                    attr_parts.push(format!("originalHeight={}", fmt_f64_attr(h)));
3279                }
3280                if let Some(w) = attrs.get("width").and_then(serde_json::Value::as_f64) {
3281                    attr_parts.push(format!("width={}", fmt_f64_attr(w)));
3282                }
3283                if !attr_parts.is_empty() {
3284                    output.push_str(&format!("{{{}}}", attr_parts.join(" ")));
3285                }
3286                output.push('\n');
3287            }
3288        }
3289        "extension" => {
3290            if let Some(ref attrs) = node.attrs {
3291                let ext_type = attrs
3292                    .get("extensionType")
3293                    .and_then(serde_json::Value::as_str)
3294                    .unwrap_or("");
3295                let ext_key = attrs
3296                    .get("extensionKey")
3297                    .and_then(serde_json::Value::as_str)
3298                    .unwrap_or("");
3299                let mut attr_parts = vec![format!("type={ext_type}"), format!("key={ext_key}")];
3300                if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3301                    attr_parts.push(format!("layout={layout}"));
3302                }
3303                if let Some(params) = attrs.get("parameters") {
3304                    if let Ok(json_str) = serde_json::to_string(params) {
3305                        attr_parts.push(format!("params='{json_str}'"));
3306                    }
3307                }
3308                maybe_push_local_id(attrs, &mut attr_parts, opts);
3309                output.push_str(&format!("::extension{{{}}}\n", attr_parts.join(" ")));
3310            }
3311        }
3312        "panel" => {
3313            let panel_type = node
3314                .attrs
3315                .as_ref()
3316                .and_then(|a| a.get("panelType"))
3317                .and_then(serde_json::Value::as_str)
3318                .unwrap_or("info");
3319            let mut attr_parts = vec![format!("type={panel_type}")];
3320            if let Some(ref attrs) = node.attrs {
3321                if let Some(icon) = attrs.get("panelIcon").and_then(serde_json::Value::as_str) {
3322                    attr_parts.push(format!("icon=\"{icon}\""));
3323                }
3324                if let Some(color) = attrs.get("panelColor").and_then(serde_json::Value::as_str) {
3325                    attr_parts.push(format!("color=\"{color}\""));
3326                }
3327            }
3328            output.push_str(&format!(":::panel{{{}}}\n", attr_parts.join(" ")));
3329            if let Some(ref content) = node.content {
3330                render_block_children(content, output, opts);
3331            }
3332            output.push_str(":::\n");
3333        }
3334        "expand" | "nestedExpand" => {
3335            let directive_name = if node.node_type == "nestedExpand" {
3336                "nested-expand"
3337            } else {
3338                "expand"
3339            };
3340            let mut attr_parts = Vec::new();
3341            if let Some(t) = node
3342                .attrs
3343                .as_ref()
3344                .and_then(|a| a.get("title"))
3345                .and_then(serde_json::Value::as_str)
3346            {
3347                attr_parts.push(format!("title=\"{t}\""));
3348            }
3349            // Check top-level localId first, then fall back to attrs.localId
3350            if let Some(ref lid) = node.local_id {
3351                if !opts.strip_local_ids && lid != "00000000-0000-0000-0000-000000000000" {
3352                    attr_parts.push(format!("localId={lid}"));
3353                }
3354            } else if let Some(ref attrs) = node.attrs {
3355                maybe_push_local_id(attrs, &mut attr_parts, opts);
3356            }
3357            // Emit top-level parameters as params='...'
3358            if let Some(ref params) = node.parameters {
3359                if let Ok(json_str) = serde_json::to_string(params) {
3360                    attr_parts.push(format!("params='{json_str}'"));
3361                }
3362            }
3363            if attr_parts.is_empty() {
3364                output.push_str(&format!(":::{directive_name}\n"));
3365            } else {
3366                output.push_str(&format!(
3367                    ":::{directive_name}{{{}}}\n",
3368                    attr_parts.join(" ")
3369                ));
3370            }
3371            if let Some(ref content) = node.content {
3372                render_block_children(content, output, opts);
3373            }
3374            output.push_str(":::\n");
3375        }
3376        "layoutSection" => {
3377            output.push_str("::::layout\n");
3378            if let Some(ref content) = node.content {
3379                for child in content {
3380                    if child.node_type == "layoutColumn" {
3381                        let width_str = child
3382                            .attrs
3383                            .as_ref()
3384                            .and_then(|a| a.get("width"))
3385                            .and_then(fmt_numeric_attr)
3386                            .unwrap_or_else(|| "50".to_string());
3387                        let mut parts = vec![format!("width={width_str}")];
3388                        if let Some(ref attrs) = child.attrs {
3389                            maybe_push_local_id(attrs, &mut parts, opts);
3390                        }
3391                        output.push_str(&format!(":::column{{{}}}\n", parts.join(" ")));
3392                        if let Some(ref col_content) = child.content {
3393                            render_block_children(col_content, output, opts);
3394                        }
3395                        output.push_str(":::\n");
3396                    }
3397                }
3398            }
3399            output.push_str("::::\n");
3400        }
3401        "decisionList" => {
3402            output.push_str(":::decisions\n");
3403            if let Some(ref content) = node.content {
3404                for item in content {
3405                    output.push_str("- <> ");
3406                    render_list_item_content(item, output, opts);
3407                }
3408            }
3409            output.push_str(":::\n");
3410        }
3411        "bodiedExtension" => {
3412            if let Some(ref attrs) = node.attrs {
3413                let ext_type = attrs
3414                    .get("extensionType")
3415                    .and_then(serde_json::Value::as_str)
3416                    .unwrap_or("");
3417                let ext_key = attrs
3418                    .get("extensionKey")
3419                    .and_then(serde_json::Value::as_str)
3420                    .unwrap_or("");
3421                let mut attr_parts = vec![format!("type={ext_type}"), format!("key={ext_key}")];
3422                if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3423                    attr_parts.push(format!("layout={layout}"));
3424                }
3425                if let Some(params) = attrs.get("parameters") {
3426                    if let Ok(json_str) = serde_json::to_string(params) {
3427                        attr_parts.push(format!("params='{json_str}'"));
3428                    }
3429                }
3430                maybe_push_local_id(attrs, &mut attr_parts, opts);
3431                output.push_str(&format!(":::extension{{{}}}\n", attr_parts.join(" ")));
3432                if let Some(ref content) = node.content {
3433                    render_block_children(content, output, opts);
3434                }
3435                output.push_str(":::\n");
3436            }
3437        }
3438        _ => {
3439            // Preserve unsupported nodes as JSON in adf-unsupported code blocks
3440            if let Ok(json) = serde_json::to_string_pretty(node) {
3441                output.push_str("```adf-unsupported\n");
3442                output.push_str(&json);
3443                output.push_str("\n```\n");
3444            }
3445        }
3446    }
3447
3448    // Emit block-level attribute marks (align, indent, breakout) and localId
3449    let mut parts = Vec::new();
3450    if let Some(ref marks) = node.marks {
3451        for mark in marks {
3452            match mark.mark_type.as_str() {
3453                "alignment" => {
3454                    if let Some(align) = mark
3455                        .attrs
3456                        .as_ref()
3457                        .and_then(|a| a.get("align"))
3458                        .and_then(serde_json::Value::as_str)
3459                    {
3460                        parts.push(format!("align={align}"));
3461                    }
3462                }
3463                "indentation" => {
3464                    if let Some(level) = mark
3465                        .attrs
3466                        .as_ref()
3467                        .and_then(|a| a.get("level"))
3468                        .and_then(serde_json::Value::as_u64)
3469                    {
3470                        parts.push(format!("indent={level}"));
3471                    }
3472                }
3473                "breakout" => {
3474                    if let Some(mode) = mark
3475                        .attrs
3476                        .as_ref()
3477                        .and_then(|a| a.get("mode"))
3478                        .and_then(serde_json::Value::as_str)
3479                    {
3480                        parts.push(format!("breakout={mode}"));
3481                    }
3482                    if let Some(width) = mark
3483                        .attrs
3484                        .as_ref()
3485                        .and_then(|a| a.get("width"))
3486                        .and_then(serde_json::Value::as_u64)
3487                    {
3488                        parts.push(format!("breakoutWidth={width}"));
3489                    }
3490                }
3491                _ => {}
3492            }
3493        }
3494    }
3495    // Skip localId for node types that already include it in their directive attrs.
3496    // For paragraphs, localId is included in the ::paragraph directive when the
3497    // paragraph uses directive form (empty or whitespace-only content).
3498    let para_used_directive = node.node_type == "paragraph" && {
3499        let is_empty = node.content.as_ref().map_or(true, Vec::is_empty);
3500        if is_empty {
3501            true
3502        } else {
3503            let mut buf = String::new();
3504            render_inline_content(node, &mut buf, opts);
3505            buf.trim().is_empty() && !buf.is_empty()
3506        }
3507    };
3508    if !matches!(node.node_type.as_str(), "expand" | "nestedExpand") && !para_used_directive {
3509        if let Some(ref attrs) = node.attrs {
3510            maybe_push_local_id(attrs, &mut parts, opts);
3511        }
3512    }
3513    // orderedList with explicit `attrs.order=1` needs a trailing `{order=1}`
3514    // signal so the round-trip can distinguish explicit default from omitted
3515    // attrs (issue #547). Values other than 1 are already encoded by the
3516    // list marker, so no signal is needed.
3517    if node.node_type == "orderedList" {
3518        if let Some(ref attrs) = node.attrs {
3519            if attrs.get("order").and_then(serde_json::Value::as_u64) == Some(1) {
3520                parts.push("order=1".to_string());
3521            }
3522        }
3523    }
3524    if !parts.is_empty() {
3525        output.push_str(&format!("{{{}}}\n", parts.join(" ")));
3526    }
3527}
3528
3529/// Renders the content of a list item (unwraps the paragraph layer).
3530/// Nested block children (e.g. sub-lists) are indented with two spaces.
3531///
3532/// Some ADF producers (e.g. Confluence) emit `taskItem` content without a
3533/// paragraph wrapper — the inline nodes sit directly inside the item.  We
3534/// detect this by checking whether the first child is an inline node type
3535/// and, if so, render *all* leading inline children on the first line.
3536fn render_list_item_content(item: &AdfNode, output: &mut String, opts: &RenderOptions) {
3537    let Some(ref content) = item.content else {
3538        // Still emit localId and newline for items with no content (e.g. empty taskItem).
3539        let bare = AdfNode::text("");
3540        emit_list_item_local_ids(item, &bare, output, opts);
3541        output.push('\n');
3542        return;
3543    };
3544    if content.is_empty() {
3545        let bare = AdfNode::text("");
3546        emit_list_item_local_ids(item, &bare, output, opts);
3547        output.push('\n');
3548        return;
3549    }
3550    let first = &content[0];
3551    let rest_start;
3552    if first.node_type == "paragraph" {
3553        let mut buf = String::new();
3554        render_inline_content(first, &mut buf, opts);
3555        // A trailing hardBreak produces a trailing `\\\n` in the buffer.
3556        // Strip the final newline so it doesn't create a blank line after
3557        // the list item marker, which would split the list on re-parse
3558        // (issue #472).  The `\` is kept so round-trip preserves the
3559        // hardBreak, and `output.push('\n')` below supplies the terminator.
3560        let buf = buf.trim_end_matches('\n');
3561        // Indent continuation lines produced by hardBreaks so they stay
3562        // within the list item when re-parsed (issue #402).
3563        let mut is_first_line = true;
3564        for line in buf.split('\n') {
3565            if is_first_line {
3566                output.push_str(line);
3567                is_first_line = false;
3568            } else {
3569                output.push('\n');
3570                if !line.is_empty() {
3571                    output.push_str("  ");
3572                }
3573                output.push_str(line);
3574            }
3575        }
3576        // Emit paragraph + listItem localIds as trailing inline attrs on the first line
3577        emit_list_item_local_ids(item, first, output, opts);
3578        output.push('\n');
3579        rest_start = 1;
3580    } else if is_inline_node_type(&first.node_type) {
3581        // Inline nodes without a paragraph wrapper — render them directly.
3582        rest_start = content
3583            .iter()
3584            .position(|c| !is_inline_node_type(&c.node_type))
3585            .unwrap_or(content.len());
3586        let mut buf = String::new();
3587        for child in &content[..rest_start] {
3588            render_inline_node(child, &mut buf, opts);
3589        }
3590        // Indent continuation lines produced by hardBreaks so they stay
3591        // within the list item when re-parsed (issue #521).
3592        let buf = buf.trim_end_matches('\n');
3593        let mut is_first_line = true;
3594        for line in buf.split('\n') {
3595            if is_first_line {
3596                output.push_str(line);
3597                is_first_line = false;
3598            } else {
3599                output.push('\n');
3600                if !line.is_empty() {
3601                    output.push_str("  ");
3602                }
3603                output.push_str(line);
3604            }
3605        }
3606        // No paragraph wrapper — pass a bare node so paraLocalId is omitted.
3607        let bare = AdfNode::text("");
3608        emit_list_item_local_ids(item, &bare, output, opts);
3609        output.push('\n');
3610        // Any remaining children are block nodes — fall through to the
3611        // indented-block loop below.
3612    } else if first.node_type == "taskItem" {
3613        // Malformed ADF: taskItem.content contains nested taskItem nodes
3614        // directly (seen in some Confluence pages).  Render them as an
3615        // indented nested task list to preserve the data without
3616        // corrupting the surrounding structure (issue #489).
3617        let bare = AdfNode::text("");
3618        emit_list_item_local_ids(item, &bare, output, opts);
3619        output.push('\n');
3620        for child in content {
3621            if child.node_type == "taskItem" {
3622                let state = child
3623                    .attrs
3624                    .as_ref()
3625                    .and_then(|a| a.get("state"))
3626                    .and_then(serde_json::Value::as_str)
3627                    .unwrap_or("TODO");
3628                let marker = if state == "DONE" { "- [x] " } else { "- [ ] " };
3629                output.push_str("  ");
3630                output.push_str(marker);
3631                render_list_item_content(child, output, opts);
3632            } else {
3633                let mut nested = String::new();
3634                render_block_node(child, &mut nested, opts);
3635                for line in nested.lines() {
3636                    output.push_str("  ");
3637                    output.push_str(line);
3638                    output.push('\n');
3639                }
3640            }
3641        }
3642        return;
3643    } else {
3644        // Block-level first child (e.g. codeBlock, mediaSingle).
3645        // Render to a buffer so we can:
3646        //  1. Append listItem localId attrs to the first line (issue #525)
3647        //  2. Indent continuation lines so multi-line blocks stay inside
3648        //     the list item (issue #511)
3649        let mut buf = String::new();
3650        render_block_node(first, &mut buf, opts);
3651        let bare = AdfNode::text("");
3652        let mut is_first = true;
3653        for line in buf.lines() {
3654            if is_first {
3655                output.push_str(line);
3656                emit_list_item_local_ids(item, &bare, output, opts);
3657                output.push('\n');
3658                is_first = false;
3659            } else {
3660                output.push_str("  ");
3661                output.push_str(line);
3662                output.push('\n');
3663            }
3664        }
3665        rest_start = 1;
3666    }
3667    let rest = &content[rest_start..];
3668    for (i, child) in rest.iter().enumerate() {
3669        // Separate consecutive paragraph siblings with a blank indented
3670        // line so they re-parse as distinct paragraphs rather than being
3671        // merged into one (issue #522).
3672        if child.node_type == "paragraph" {
3673            let prev_is_para = if i == 0 {
3674                // First rest child — check whether the first-line node
3675                // (rendered above) was a paragraph.
3676                first.node_type == "paragraph"
3677            } else {
3678                rest[i - 1].node_type == "paragraph"
3679            };
3680            if prev_is_para {
3681                output.push_str("  \n");
3682            }
3683        }
3684        let mut nested = String::new();
3685        render_block_node(child, &mut nested, opts);
3686        for line in nested.lines() {
3687            output.push_str("  ");
3688            output.push_str(line);
3689            output.push('\n');
3690        }
3691    }
3692}
3693
3694/// Returns `true` if the given ADF node type is an inline node.
3695fn is_inline_node_type(node_type: &str) -> bool {
3696    matches!(
3697        node_type,
3698        "text"
3699            | "hardBreak"
3700            | "inlineCard"
3701            | "emoji"
3702            | "mention"
3703            | "status"
3704            | "date"
3705            | "placeholder"
3706            | "mediaInline"
3707    )
3708}
3709
3710/// Emits trailing `{localId=... paraLocalId=...}` on a list item line
3711/// for both the listItem and its first (unwrapped) paragraph.
3712fn emit_list_item_local_ids(
3713    item: &AdfNode,
3714    paragraph: &AdfNode,
3715    output: &mut String,
3716    opts: &RenderOptions,
3717) {
3718    if opts.strip_local_ids {
3719        return;
3720    }
3721    let mut parts = Vec::new();
3722    if let Some(ref attrs) = item.attrs {
3723        maybe_push_local_id(attrs, &mut parts, opts);
3724    }
3725    if paragraph.node_type == "paragraph" {
3726        let has_real_id = paragraph
3727            .attrs
3728            .as_ref()
3729            .and_then(|a| a.get("localId"))
3730            .and_then(serde_json::Value::as_str)
3731            .filter(|id| !id.is_empty() && *id != "00000000-0000-0000-0000-000000000000");
3732        if let Some(local_id) = has_real_id {
3733            parts.push(format!("paraLocalId={local_id}"));
3734        } else if item.node_type == "taskItem" {
3735            // taskItem content may or may not have a paragraph wrapper;
3736            // emit a sentinel so the round-trip can distinguish the two
3737            // forms and restore the wrapper (issue #478).
3738            parts.push("paraLocalId=_".to_string());
3739        }
3740    }
3741    if !parts.is_empty() {
3742        output.push_str(&format!(" {{{}}}", parts.join(" ")));
3743    }
3744}
3745
3746/// Renders a table node, choosing between pipe table and directive table form.
3747fn render_table(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
3748    let Some(ref rows) = node.content else {
3749        return;
3750    };
3751
3752    if table_qualifies_for_pipe_syntax(rows) {
3753        render_pipe_table(node, rows, output, opts);
3754    } else {
3755        render_directive_table(node, rows, output, opts);
3756    }
3757}
3758
3759/// Checks whether all cells qualify for GFM pipe table syntax:
3760/// - Every cell has exactly one paragraph child with only inline nodes
3761/// - All `tableHeader` nodes appear exclusively in the first row
3762/// - The first row must contain at least one `tableHeader` (pipe tables
3763///   always treat the first row as headers, so `tableCell`-only first rows
3764///   must use directive form to preserve the cell type)
3765fn table_qualifies_for_pipe_syntax(rows: &[AdfNode]) -> bool {
3766    // Tables with caption nodes must use directive form
3767    if rows.iter().any(|n| n.node_type == "caption") {
3768        return false;
3769    }
3770    let mut first_row_has_header = false;
3771    for (row_idx, row) in rows.iter().enumerate() {
3772        let Some(ref cells) = row.content else {
3773            continue;
3774        };
3775        for cell in cells {
3776            // Header cells outside first row → must use directive form
3777            if row_idx > 0 && cell.node_type == "tableHeader" {
3778                return false;
3779            }
3780            if row_idx == 0 && cell.node_type == "tableHeader" {
3781                first_row_has_header = true;
3782            }
3783            // Check cell has exactly one paragraph with only inline content
3784            let Some(ref content) = cell.content else {
3785                continue;
3786            };
3787            if content.len() != 1 || content[0].node_type != "paragraph" {
3788                return false;
3789            }
3790            // hardBreak inside a cell produces a newline that breaks pipe
3791            // table syntax — fall back to directive form
3792            if cell_contains_hard_break(&content[0]) {
3793                return false;
3794            }
3795            // Cell-level marks (e.g., border) cannot be represented in pipe
3796            // form — fall back to directive form
3797            if cell.marks.as_ref().is_some_and(|m| !m.is_empty()) {
3798                return false;
3799            }
3800            // Paragraph-level localId would be lost in pipe form (the paragraph
3801            // is unwrapped into the cell text) — fall back to directive form
3802            if content[0]
3803                .attrs
3804                .as_ref()
3805                .and_then(|a| a.get("localId"))
3806                .is_some()
3807            {
3808                return false;
3809            }
3810        }
3811    }
3812    // First row must have at least one tableHeader for pipe syntax;
3813    // otherwise the round-trip would convert tableCell → tableHeader.
3814    first_row_has_header
3815}
3816
3817/// Returns true if a paragraph node contains any `hardBreak` inline nodes.
3818fn cell_contains_hard_break(paragraph: &AdfNode) -> bool {
3819    paragraph
3820        .content
3821        .as_ref()
3822        .is_some_and(|nodes| nodes.iter().any(|n| n.node_type == "hardBreak"))
3823}
3824
3825/// Renders a table as GFM pipe syntax.
3826fn render_pipe_table(node: &AdfNode, rows: &[AdfNode], output: &mut String, opts: &RenderOptions) {
3827    for (row_idx, row) in rows.iter().enumerate() {
3828        let Some(ref cells) = row.content else {
3829            continue;
3830        };
3831
3832        output.push('|');
3833        for cell in cells {
3834            output.push(' ');
3835            let mut cell_buf = String::new();
3836            render_cell_attrs_prefix(cell, &mut cell_buf);
3837            render_inline_content_from_first_paragraph(cell, &mut cell_buf, opts);
3838            output.push_str(&escape_pipes_in_cell(&cell_buf));
3839            output.push_str(" |");
3840        }
3841        output.push('\n');
3842
3843        // Add separator after header row
3844        if row_idx == 0 {
3845            output.push('|');
3846            for cell in cells {
3847                let align = get_cell_paragraph_alignment(cell);
3848                match align {
3849                    Some("center") => output.push_str(" :---: |"),
3850                    Some("end") => output.push_str(" ---: |"),
3851                    _ => output.push_str(" --- |"),
3852                }
3853            }
3854            output.push('\n');
3855        }
3856    }
3857
3858    // Emit table-level attrs if present
3859    render_table_level_attrs(node, output, opts);
3860}
3861
3862/// Renders a table as `::::table` directive syntax (block-content cells).
3863fn render_directive_table(
3864    node: &AdfNode,
3865    rows: &[AdfNode],
3866    output: &mut String,
3867    opts: &RenderOptions,
3868) {
3869    // Opening fence with attrs
3870    let mut attr_parts = Vec::new();
3871    if let Some(ref attrs) = node.attrs {
3872        if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3873            attr_parts.push(format!("layout={layout}"));
3874        }
3875        if let Some(numbered) = attrs
3876            .get("isNumberColumnEnabled")
3877            .and_then(serde_json::Value::as_bool)
3878        {
3879            if numbered {
3880                attr_parts.push("numbered".to_string());
3881            } else {
3882                attr_parts.push("numbered=false".to_string());
3883            }
3884        }
3885        if let Some(tw) = attrs.get("width").and_then(serde_json::Value::as_f64) {
3886            let tw_str = if tw.fract() == 0.0 {
3887                (tw as u64).to_string()
3888            } else {
3889                tw.to_string()
3890            };
3891            attr_parts.push(format!("width={tw_str}"));
3892        }
3893        maybe_push_local_id(attrs, &mut attr_parts, opts);
3894    }
3895    if attr_parts.is_empty() {
3896        output.push_str("::::table\n");
3897    } else {
3898        output.push_str(&format!("::::table{{{}}}\n", attr_parts.join(" ")));
3899    }
3900
3901    for row in rows {
3902        if row.node_type == "caption" {
3903            let mut cap_parts = Vec::new();
3904            if let Some(ref attrs) = row.attrs {
3905                maybe_push_local_id(attrs, &mut cap_parts, opts);
3906            }
3907            if cap_parts.is_empty() {
3908                output.push_str(":::caption\n");
3909            } else {
3910                output.push_str(&format!(":::caption{{{}}}\n", cap_parts.join(" ")));
3911            }
3912            if let Some(ref content) = row.content {
3913                for child in content {
3914                    render_inline_node(child, output, opts);
3915                }
3916                output.push('\n');
3917            }
3918            output.push_str(":::\n");
3919            continue;
3920        }
3921        let Some(ref cells) = row.content else {
3922            continue;
3923        };
3924        // Emit :::tr with optional localId
3925        let mut tr_attrs = Vec::new();
3926        if let Some(ref attrs) = row.attrs {
3927            maybe_push_local_id(attrs, &mut tr_attrs, opts);
3928        }
3929        if tr_attrs.is_empty() {
3930            output.push_str(":::tr\n");
3931        } else {
3932            output.push_str(&format!(":::tr{{{}}}\n", tr_attrs.join(" ")));
3933        }
3934        for cell in cells {
3935            let directive_name = if cell.node_type == "tableHeader" {
3936                "th"
3937            } else {
3938                "td"
3939            };
3940            let mut cell_attr_str = build_cell_attrs_string(cell);
3941            // Append localId to cell attrs if present
3942            if let Some(ref attrs) = cell.attrs {
3943                let mut lid_parts = Vec::new();
3944                maybe_push_local_id(attrs, &mut lid_parts, opts);
3945                if !lid_parts.is_empty() {
3946                    if !cell_attr_str.is_empty() {
3947                        cell_attr_str.push(' ');
3948                    }
3949                    cell_attr_str.push_str(&lid_parts.join(" "));
3950                }
3951            }
3952            // Append border mark attrs if present
3953            if let Some(ref marks) = cell.marks {
3954                for mark in marks {
3955                    if mark.mark_type == "border" {
3956                        if let Some(ref attrs) = mark.attrs {
3957                            if let Some(color) =
3958                                attrs.get("color").and_then(serde_json::Value::as_str)
3959                            {
3960                                if !cell_attr_str.is_empty() {
3961                                    cell_attr_str.push(' ');
3962                                }
3963                                cell_attr_str.push_str(&format!("border-color={color}"));
3964                            }
3965                            if let Some(size) =
3966                                attrs.get("size").and_then(serde_json::Value::as_u64)
3967                            {
3968                                if !cell_attr_str.is_empty() {
3969                                    cell_attr_str.push(' ');
3970                                }
3971                                cell_attr_str.push_str(&format!("border-size={size}"));
3972                            }
3973                        }
3974                    }
3975                }
3976            }
3977            let has_marks = cell.marks.as_ref().is_some_and(|m| !m.is_empty());
3978            if cell_attr_str.is_empty() && cell.attrs.is_none() && !has_marks {
3979                output.push_str(&format!(":::{directive_name}\n"));
3980            } else {
3981                output.push_str(&format!(":::{directive_name}{{{cell_attr_str}}}\n"));
3982            }
3983            if let Some(ref content) = cell.content {
3984                render_block_children(content, output, opts);
3985            }
3986            output.push_str(":::\n");
3987        }
3988        output.push_str(":::\n");
3989    }
3990
3991    output.push_str("::::\n");
3992}
3993
3994/// Returns `true` when an attribute value must be quoted to survive round-trip
3995/// through the `{key=value}` attribute parser (which stops unquoted values at
3996/// whitespace or `}`).
3997fn needs_attr_quoting(value: &str) -> bool {
3998    value.contains(|c: char| c.is_whitespace() || c == '}' || c == '(' || c == ')' || c == ',')
3999}
4000
4001/// Builds a JFM attribute string from ADF cell attributes.
4002fn build_cell_attrs_string(cell: &AdfNode) -> String {
4003    let Some(ref attrs) = cell.attrs else {
4004        return String::new();
4005    };
4006    let mut parts = Vec::new();
4007    if let Some(colspan) = attrs.get("colspan").and_then(serde_json::Value::as_u64) {
4008        parts.push(format!("colspan={colspan}"));
4009    }
4010    if let Some(rowspan) = attrs.get("rowspan").and_then(serde_json::Value::as_u64) {
4011        parts.push(format!("rowspan={rowspan}"));
4012    }
4013    if let Some(bg) = attrs.get("background").and_then(serde_json::Value::as_str) {
4014        if needs_attr_quoting(bg) {
4015            let escaped = bg.replace('\\', "\\\\").replace('"', "\\\"");
4016            parts.push(format!("bg=\"{escaped}\""));
4017        } else {
4018            parts.push(format!("bg={bg}"));
4019        }
4020    }
4021    if let Some(colwidth) = attrs.get("colwidth").and_then(serde_json::Value::as_array) {
4022        let widths: Vec<String> = colwidth
4023            .iter()
4024            .filter_map(|v| {
4025                // Preserve the original number type: integers stay as integers,
4026                // floats stay as floats (e.g. Confluence's 254.0 representation).
4027                if let Some(n) = v.as_u64() {
4028                    Some(n.to_string())
4029                } else if let Some(n) = v.as_f64() {
4030                    if n.fract() == 0.0 {
4031                        format!("{n:.1}")
4032                    } else {
4033                        n.to_string()
4034                    }
4035                    .into()
4036                } else {
4037                    None
4038                }
4039            })
4040            .collect();
4041        if !widths.is_empty() {
4042            parts.push(format!("colwidth={}", widths.join(",")));
4043        }
4044    }
4045    parts.join(" ")
4046}
4047
4048/// Renders `{attrs}` prefix for a pipe table cell (background, colspan, etc.).
4049fn render_cell_attrs_prefix(cell: &AdfNode, output: &mut String) {
4050    let Some(ref _attrs) = cell.attrs else {
4051        return;
4052    };
4053    let attr_str = build_cell_attrs_string(cell);
4054    if attr_str.is_empty() {
4055        output.push_str("{} ");
4056    } else {
4057        output.push_str(&format!("{{{attr_str}}} "));
4058    }
4059}
4060
4061/// Gets the alignment mark value from the paragraph inside a table cell.
4062fn get_cell_paragraph_alignment(cell: &AdfNode) -> Option<&str> {
4063    let content = cell.content.as_ref()?;
4064    let para = content.first()?;
4065    let marks = para.marks.as_ref()?;
4066    marks.iter().find_map(|m| {
4067        if m.mark_type == "alignment" {
4068            m.attrs
4069                .as_ref()
4070                .and_then(|a| a.get("align"))
4071                .and_then(serde_json::Value::as_str)
4072        } else {
4073            None
4074        }
4075    })
4076}
4077
4078/// Emits table-level attributes if present.
4079fn render_table_level_attrs(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4080    if let Some(ref attrs) = node.attrs {
4081        let mut parts = Vec::new();
4082        if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
4083            parts.push(format!("layout={layout}"));
4084        }
4085        if let Some(numbered) = attrs
4086            .get("isNumberColumnEnabled")
4087            .and_then(serde_json::Value::as_bool)
4088        {
4089            if numbered {
4090                parts.push("numbered".to_string());
4091            } else {
4092                parts.push("numbered=false".to_string());
4093            }
4094        }
4095        if let Some(tw_str) = attrs.get("width").and_then(fmt_numeric_attr) {
4096            parts.push(format!("width={tw_str}"));
4097        }
4098        maybe_push_local_id(attrs, &mut parts, opts);
4099        if !parts.is_empty() {
4100            output.push_str(&format!("{{{}}}\n", parts.join(" ")));
4101        }
4102    }
4103}
4104
4105/// Renders inline content from the first paragraph child of a table cell.
4106fn render_inline_content_from_first_paragraph(
4107    cell: &AdfNode,
4108    output: &mut String,
4109    opts: &RenderOptions,
4110) {
4111    if let Some(ref content) = cell.content {
4112        if let Some(first) = content.first() {
4113            if first.node_type == "paragraph" {
4114                render_inline_content(first, output, opts);
4115            }
4116        }
4117    }
4118}
4119
4120/// Appends border mark attributes (border-color, border-size) to a parts vec.
4121fn push_border_mark_attrs(marks: &Option<Vec<AdfMark>>, parts: &mut Vec<String>) {
4122    if let Some(ref marks) = marks {
4123        for mark in marks {
4124            if mark.mark_type == "border" {
4125                if let Some(ref attrs) = mark.attrs {
4126                    if let Some(color) = attrs.get("color").and_then(serde_json::Value::as_str) {
4127                        parts.push(format!("border-color={color}"));
4128                    }
4129                    if let Some(size) = attrs.get("size").and_then(serde_json::Value::as_u64) {
4130                        parts.push(format!("border-size={size}"));
4131                    }
4132                }
4133            }
4134        }
4135    }
4136}
4137
4138/// Renders a media node as a markdown image, with optional parent (mediaSingle) attrs.
4139fn render_media(
4140    node: &AdfNode,
4141    parent_attrs: Option<&serde_json::Value>,
4142    output: &mut String,
4143    opts: &RenderOptions,
4144) {
4145    if let Some(ref attrs) = node.attrs {
4146        let media_type = attrs
4147            .get("type")
4148            .and_then(serde_json::Value::as_str)
4149            .unwrap_or("external");
4150        let alt = attrs
4151            .get("alt")
4152            .and_then(serde_json::Value::as_str)
4153            .unwrap_or("");
4154
4155        if media_type == "file" {
4156            // Confluence file attachment — encode metadata in {attrs} block so it survives round-trip
4157            output.push_str(&format!("![{alt}]()"));
4158            let mut parts = vec!["type=file".to_string()];
4159            if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4160                parts.push(format_kv("id", id));
4161            }
4162            if let Some(collection) = attrs.get("collection").and_then(serde_json::Value::as_str) {
4163                parts.push(format_kv("collection", collection));
4164            }
4165            if let Some(occurrence_key) = attrs
4166                .get("occurrenceKey")
4167                .and_then(serde_json::Value::as_str)
4168            {
4169                parts.push(format_kv("occurrenceKey", occurrence_key));
4170            }
4171            if let Some(height_str) = attrs.get("height").and_then(fmt_numeric_attr) {
4172                parts.push(format!("height={height_str}"));
4173            }
4174            if let Some(width_str) = attrs.get("width").and_then(fmt_numeric_attr) {
4175                parts.push(format!("width={width_str}"));
4176            }
4177            maybe_push_local_id(attrs, &mut parts, opts);
4178            // Encode mediaSingle layout/width/widthType if non-default
4179            if let Some(p_attrs) = parent_attrs {
4180                if let Some(layout) = p_attrs.get("layout").and_then(serde_json::Value::as_str) {
4181                    if layout != "center" {
4182                        parts.push(format!("layout={layout}"));
4183                    }
4184                }
4185                if let Some(ms_width_str) = p_attrs.get("width").and_then(fmt_numeric_attr) {
4186                    parts.push(format!("mediaWidth={ms_width_str}"));
4187                }
4188                if let Some(wt) = p_attrs.get("widthType").and_then(serde_json::Value::as_str) {
4189                    parts.push(format!("widthType={wt}"));
4190                }
4191                if let Some(mode) = p_attrs.get("mode").and_then(serde_json::Value::as_str) {
4192                    parts.push(format!("mode={mode}"));
4193                }
4194            }
4195            push_border_mark_attrs(&node.marks, &mut parts);
4196            output.push_str(&format!("{{{}}}", parts.join(" ")));
4197        } else {
4198            // External image
4199            let url = attrs
4200                .get("url")
4201                .and_then(serde_json::Value::as_str)
4202                .unwrap_or("");
4203            output.push_str(&format!("![{alt}]({url})"));
4204
4205            // Emit {layout=... width=... widthType=... mode=... localId=...} if non-default attrs present
4206            {
4207                let mut parts = Vec::new();
4208                if let Some(p_attrs) = parent_attrs {
4209                    let layout = p_attrs.get("layout").and_then(serde_json::Value::as_str);
4210                    let width_str = p_attrs.get("width").and_then(fmt_numeric_attr);
4211                    let width_type = p_attrs.get("widthType").and_then(serde_json::Value::as_str);
4212                    if let Some(l) = layout {
4213                        if l != "center" {
4214                            parts.push(format!("layout={l}"));
4215                        }
4216                    }
4217                    if let Some(w) = width_str {
4218                        parts.push(format!("width={w}"));
4219                    }
4220                    if let Some(wt) = width_type {
4221                        parts.push(format!("widthType={wt}"));
4222                    }
4223                    if let Some(mode) = p_attrs.get("mode").and_then(serde_json::Value::as_str) {
4224                        parts.push(format!("mode={mode}"));
4225                    }
4226                }
4227                maybe_push_local_id(attrs, &mut parts, opts);
4228                push_border_mark_attrs(&node.marks, &mut parts);
4229                if !parts.is_empty() {
4230                    output.push_str(&format!("{{{}}}", parts.join(" ")));
4231                }
4232            }
4233        }
4234
4235        output.push('\n');
4236    }
4237}
4238
4239/// Renders inline content (text nodes with marks) from a block node's children.
4240fn render_inline_content(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4241    if let Some(ref content) = node.content {
4242        for child in content {
4243            render_inline_node(child, output, opts);
4244        }
4245    }
4246}
4247
4248/// Renders a single inline ADF node to markdown.
4249fn render_inline_node(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4250    match node.node_type.as_str() {
4251        "text" => {
4252            let text = node.text.as_deref().unwrap_or("");
4253            let marks = node.marks.as_deref().unwrap_or(&[]);
4254            let has_code = marks.iter().any(|m| m.mark_type == "code");
4255            // Issue #477: Escape literal backslashes before the newline
4256            // encoding below so they are not consumed as JFM escape
4257            // sequences on round-trip.  Code marks emit content verbatim,
4258            // so backslash escaping is skipped for them.
4259            let owned;
4260            let text = if !has_code {
4261                owned = text.replace('\\', "\\\\");
4262                owned.as_str()
4263            } else {
4264                text
4265            };
4266            // Issue #454: A literal newline inside a text node is escaped
4267            // as the two-character sequence `\n` so it survives round-trip
4268            // as a single text node instead of splitting into paragraphs or
4269            // being converted to hardBreak nodes.
4270            let owned_nl;
4271            let text = if text.contains('\n') {
4272                owned_nl = text.replace('\n', "\\n");
4273                owned_nl.as_str()
4274            } else {
4275                text
4276            };
4277            // Issue #510: Two or more trailing spaces at the end of a text
4278            // node would be misinterpreted as a hardBreak marker on
4279            // round-trip (and collapse the following paragraph).  Escape the
4280            // last space with a backslash so the parser treats it as a
4281            // literal space instead of a line-break signal.
4282            let owned_ts;
4283            let text = if !has_code && text.ends_with("  ") {
4284                let mut s = text.to_string();
4285                // Insert backslash before the final space: "foo  " → "foo \ "
4286                s.insert(s.len() - 1, '\\');
4287                owned_ts = s;
4288                owned_ts.as_str()
4289            } else {
4290                text
4291            };
4292            render_marked_text(text, marks, output);
4293        }
4294        "hardBreak" => {
4295            output.push_str("\\\n");
4296        }
4297        other => {
4298            // Issue #471: Non-text inline nodes (emoji, status, date, mention, etc.)
4299            // may carry annotation marks. Render the node body first, then wrap it
4300            // in bracketed-span syntax if annotation marks are present.
4301            let mut body = String::new();
4302            render_non_text_inline_body(other, node, &mut body, opts);
4303
4304            let annotations: Vec<&AdfMark> = node
4305                .marks
4306                .as_deref()
4307                .unwrap_or(&[])
4308                .iter()
4309                .filter(|m| m.mark_type == "annotation")
4310                .collect();
4311
4312            if annotations.is_empty() {
4313                output.push_str(&body);
4314            } else {
4315                let mut attr_parts = Vec::new();
4316                for ann in &annotations {
4317                    if let Some(ref attrs) = ann.attrs {
4318                        if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4319                            let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4320                            attr_parts.push(format!("annotation-id=\"{escaped}\""));
4321                        }
4322                        if let Some(at) = attrs
4323                            .get("annotationType")
4324                            .and_then(serde_json::Value::as_str)
4325                        {
4326                            attr_parts.push(format!("annotation-type={at}"));
4327                        }
4328                    }
4329                }
4330                output.push('[');
4331                output.push_str(&body);
4332                output.push_str("]{");
4333                output.push_str(&attr_parts.join(" "));
4334                output.push('}');
4335            }
4336        }
4337    }
4338}
4339
4340/// Renders the body of a non-text inline node (without mark wrapping).
4341fn render_non_text_inline_body(
4342    node_type: &str,
4343    node: &AdfNode,
4344    output: &mut String,
4345    opts: &RenderOptions,
4346) {
4347    match node_type {
4348        "inlineCard" => {
4349            if let Some(ref attrs) = node.attrs {
4350                if let Some(url) = attrs.get("url").and_then(serde_json::Value::as_str) {
4351                    let mut attr_parts = Vec::new();
4352                    if url_safe_in_bracket_content(url) {
4353                        output.push_str(":card[");
4354                        output.push_str(url);
4355                        output.push(']');
4356                    } else {
4357                        // URL would break `:card[URL]` parsing (e.g. contains an
4358                        // unbalanced `]` or a newline).  Fall back to quoted
4359                        // attribute form so the URL round-trips losslessly.
4360                        output.push_str(":card[]");
4361                        let escaped = url.replace('\\', "\\\\").replace('"', "\\\"");
4362                        attr_parts.push(format!("url=\"{escaped}\""));
4363                    }
4364                    maybe_push_local_id(attrs, &mut attr_parts, opts);
4365                    if !attr_parts.is_empty() {
4366                        output.push('{');
4367                        output.push_str(&attr_parts.join(" "));
4368                        output.push('}');
4369                    }
4370                }
4371            }
4372        }
4373        "emoji" => {
4374            if let Some(ref attrs) = node.attrs {
4375                if let Some(short_name) = attrs.get("shortName").and_then(serde_json::Value::as_str)
4376                {
4377                    output.push(':');
4378                    let name = short_name.strip_prefix(':').unwrap_or(short_name);
4379                    let name = name.strip_suffix(':').unwrap_or(name);
4380                    output.push_str(name);
4381                    output.push(':');
4382
4383                    let mut parts = Vec::new();
4384                    let escaped_sn = short_name.replace('\\', "\\\\").replace('"', "\\\"");
4385                    parts.push(format!("shortName=\"{escaped_sn}\""));
4386                    if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4387                        let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4388                        parts.push(format!("id=\"{escaped}\""));
4389                    }
4390                    if let Some(text) = attrs.get("text").and_then(serde_json::Value::as_str) {
4391                        let escaped = text.replace('\\', "\\\\").replace('"', "\\\"");
4392                        parts.push(format!("text=\"{escaped}\""));
4393                    }
4394                    maybe_push_local_id(attrs, &mut parts, opts);
4395                    output.push('{');
4396                    output.push_str(&parts.join(" "));
4397                    output.push('}');
4398                }
4399            }
4400        }
4401        "status" => {
4402            if let Some(ref attrs) = node.attrs {
4403                let text = attrs
4404                    .get("text")
4405                    .and_then(serde_json::Value::as_str)
4406                    .unwrap_or("");
4407                let color = attrs
4408                    .get("color")
4409                    .and_then(serde_json::Value::as_str)
4410                    .unwrap_or("neutral");
4411                let mut attr_parts = vec![format!("color={color}")];
4412                if let Some(style) = attrs.get("style").and_then(serde_json::Value::as_str) {
4413                    attr_parts.push(format!("style={style}"));
4414                }
4415                maybe_push_local_id(attrs, &mut attr_parts, opts);
4416                output.push_str(&format!(":status[{text}]{{{}}}", attr_parts.join(" ")));
4417            }
4418        }
4419        "date" => {
4420            if let Some(ref attrs) = node.attrs {
4421                if let Some(timestamp) = attrs.get("timestamp").and_then(serde_json::Value::as_str)
4422                {
4423                    let display = epoch_ms_to_iso_date(timestamp);
4424                    let mut attr_parts = vec![format!("timestamp={timestamp}")];
4425                    maybe_push_local_id(attrs, &mut attr_parts, opts);
4426                    output.push_str(&format!(":date[{display}]{{{}}}", attr_parts.join(" ")));
4427                }
4428            }
4429        }
4430        "mention" => {
4431            if let Some(ref attrs) = node.attrs {
4432                let id = attrs
4433                    .get("id")
4434                    .and_then(serde_json::Value::as_str)
4435                    .unwrap_or("");
4436                let text = attrs
4437                    .get("text")
4438                    .and_then(serde_json::Value::as_str)
4439                    .unwrap_or("");
4440                let mut attr_parts = vec![format!("id={id}")];
4441                if let Some(ut) = attrs.get("userType").and_then(serde_json::Value::as_str) {
4442                    attr_parts.push(format!("userType={ut}"));
4443                }
4444                if let Some(al) = attrs.get("accessLevel").and_then(serde_json::Value::as_str) {
4445                    attr_parts.push(format!("accessLevel={al}"));
4446                }
4447                maybe_push_local_id(attrs, &mut attr_parts, opts);
4448                output.push_str(&format!(":mention[{text}]{{{}}}", attr_parts.join(" ")));
4449            }
4450        }
4451        "placeholder" => {
4452            if let Some(ref attrs) = node.attrs {
4453                let text = attrs
4454                    .get("text")
4455                    .and_then(serde_json::Value::as_str)
4456                    .unwrap_or("");
4457                output.push_str(&format!(":placeholder[{text}]"));
4458            }
4459        }
4460        "inlineExtension" => {
4461            if let Some(ref attrs) = node.attrs {
4462                let ext_type = attrs
4463                    .get("extensionType")
4464                    .and_then(serde_json::Value::as_str)
4465                    .unwrap_or("");
4466                let ext_key = attrs
4467                    .get("extensionKey")
4468                    .and_then(serde_json::Value::as_str)
4469                    .unwrap_or("");
4470                let fallback = node.text.as_deref().unwrap_or("");
4471                output.push_str(&format!(
4472                    ":extension[{fallback}]{{type={ext_type} key={ext_key}}}"
4473                ));
4474            }
4475        }
4476        "mediaInline" => {
4477            if let Some(ref attrs) = node.attrs {
4478                let mut attr_parts = Vec::new();
4479                if let Some(media_type) = attrs.get("type").and_then(serde_json::Value::as_str) {
4480                    attr_parts.push(format_kv("type", media_type));
4481                }
4482                if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4483                    attr_parts.push(format_kv("id", id));
4484                }
4485                if let Some(collection) =
4486                    attrs.get("collection").and_then(serde_json::Value::as_str)
4487                {
4488                    attr_parts.push(format_kv("collection", collection));
4489                }
4490                if let Some(url) = attrs.get("url").and_then(serde_json::Value::as_str) {
4491                    attr_parts.push(format_kv("url", url));
4492                }
4493                if let Some(alt) = attrs.get("alt").and_then(serde_json::Value::as_str) {
4494                    attr_parts.push(format_kv("alt", alt));
4495                }
4496                if let Some(width) = attrs.get("width").and_then(serde_json::Value::as_u64) {
4497                    attr_parts.push(format!("width={width}"));
4498                }
4499                if let Some(height) = attrs.get("height").and_then(serde_json::Value::as_u64) {
4500                    attr_parts.push(format!("height={height}"));
4501                }
4502                maybe_push_local_id(attrs, &mut attr_parts, opts);
4503                output.push_str(&format!(":media-inline[]{{{}}}", attr_parts.join(" ")));
4504            }
4505        }
4506        _ => {
4507            output.push_str(&format!("<!-- unsupported inline: {} -->", node.node_type));
4508        }
4509    }
4510}
4511
4512/// Renders text with ADF marks applied as markdown syntax.
4513///
4514/// Mark ordering is preserved by walking the marks array in order and emitting
4515/// one wrapper per mark (outermost first, innermost last).  The resulting
4516/// markdown round-trips back to the original mark sequence because the parser
4517/// reconstructs marks outside-in from the nested delimiter structure.
4518///
4519/// When both `strong` and `em` are present, em is rendered with `_` instead of
4520/// `*` to avoid ambiguity (e.g., `_**text**_` rather than `***text***`).  The
4521/// single exception is `[strong, em]` (exactly those two marks in that order),
4522/// which is rendered as `***text***` to preserve the familiar compact form;
4523/// the parser's triple-delimiter rule round-trips it back to `[strong, em]`.
4524fn render_marked_text(text: &str, marks: &[AdfMark], output: &mut String) {
4525    if marks.iter().any(|m| m.mark_type == "code") {
4526        render_code_marked_text(text, marks, output);
4527        return;
4528    }
4529
4530    let has_link = marks.iter().any(|m| m.mark_type == "link");
4531    let has_strong = marks.iter().any(|m| m.mark_type == "strong");
4532    let has_em = marks.iter().any(|m| m.mark_type == "em");
4533
4534    // Compact form for the common [strong, em] case: ***text***.  em is
4535    // rendered with `*` here (as part of the `***` triple delimiter), so
4536    // underscores in the content don't need escaping.
4537    if marks.len() == 2 && marks[0].mark_type == "strong" && marks[1].mark_type == "em" {
4538        let escaped = escape_emphasis_markers(text);
4539        let escaped = escape_emoji_shortcodes(&escaped);
4540        let escaped = escape_backticks(&escaped);
4541        let escaped = escape_bare_urls(&escaped);
4542        output.push_str("***");
4543        output.push_str(&escaped);
4544        output.push_str("***");
4545        return;
4546    }
4547
4548    // When both strong and em are present (in any order), em uses `_` instead
4549    // of `*` to avoid the `***` triple-delimiter ambiguity.  Otherwise em uses
4550    // `*`, which sidesteps intraword-underscore pitfalls for plain em text.
4551    let em_delim = if has_strong && has_em { "_" } else { "*" };
4552
4553    // Text must also escape `_` when em renders as `_..._` — otherwise any
4554    // underscore in the content would close the emphasis span early.
4555    let escaped = if em_delim == "_" {
4556        escape_emphasis_markers_with_underscore(text)
4557    } else {
4558        escape_emphasis_markers(text)
4559    };
4560    let escaped = escape_emoji_shortcodes(&escaped);
4561    let escaped = escape_backticks(&escaped);
4562    // Always escape bare URLs so they are not re-parsed as `inlineCard`
4563    // nodes on round-trip.  When the text carries a link mark, also escape
4564    // `[` and `]` so they do not terminate the enclosing `[…]` link syntax
4565    // (issue #493).  Escaping bare URLs inside link text additionally
4566    // prevents `\[`/`\]` escapes from leaking through the URL-as-link-text
4567    // fast path and from corrupting an auto-detected bare URL inside the
4568    // link display text (issue #551).
4569    let escaped = escape_bare_urls(&escaped);
4570    let escaped = if has_link {
4571        escape_link_brackets(&escaped)
4572    } else {
4573        escaped
4574    };
4575
4576    // Collect (open, close) wrappers in mark order, outermost first.  Consecutive
4577    // span-attr or bracketed-span marks that happen to be in the parser's
4578    // canonical order (so the merged wrapper parses back to the same mark
4579    // sequence) are merged into a single wrapper; otherwise each mark gets its
4580    // own nested wrapper so that the mark ordering survives the round-trip.
4581    let mut wrappers: Vec<(String, String)> = Vec::new();
4582    let mut i = 0;
4583    while i < marks.len() {
4584        match marks[i].mark_type.as_str() {
4585            "em" => {
4586                wrappers.push((em_delim.to_string(), em_delim.to_string()));
4587                i += 1;
4588            }
4589            "strong" => {
4590                wrappers.push(("**".to_string(), "**".to_string()));
4591                i += 1;
4592            }
4593            "strike" => {
4594                wrappers.push(("~~".to_string(), "~~".to_string()));
4595                i += 1;
4596            }
4597            "link" => {
4598                let href = link_href(&marks[i]);
4599                wrappers.push(("[".to_string(), format!("]({href})")));
4600                i += 1;
4601            }
4602            "textColor" | "backgroundColor" | "subsup" => {
4603                let start = i;
4604                while i < marks.len() && is_span_attr_mark(&marks[i].mark_type) {
4605                    i += 1;
4606                }
4607                emit_span_attr_wrappers(&marks[start..i], &mut wrappers);
4608            }
4609            "underline" | "annotation" => {
4610                let start = i;
4611                while i < marks.len() && is_bracketed_span_mark(&marks[i].mark_type) {
4612                    i += 1;
4613                }
4614                emit_bracketed_wrappers(&marks[start..i], &mut wrappers);
4615            }
4616            _ => {
4617                i += 1;
4618            }
4619        }
4620    }
4621
4622    // Apply wrappers from innermost (last) to outermost (first).
4623    let mut result = escaped;
4624    for (open, close) in wrappers.iter().rev() {
4625        result.insert_str(0, open);
4626        result.push_str(close);
4627    }
4628    output.push_str(&result);
4629}
4630
4631/// Renders a text node with a `code` mark.  Code content is emitted verbatim
4632/// inside backticks, optionally wrapped by a link and/or by `:span`/bracketed-
4633/// span carrying span-attr (`textColor`, `backgroundColor`, `subsup`) and
4634/// bracketed-span (`underline`, `annotation`) marks.  No `em`/`strong`/`strike`
4635/// formatting is applied because markdown code spans do not support nested
4636/// emphasis (issue #554: previously textColor/bg/subsup/underline were
4637/// silently dropped when combined with a code mark).
4638fn render_code_marked_text(text: &str, marks: &[AdfMark], output: &mut String) {
4639    let link_mark = marks.iter().find(|m| m.mark_type == "link");
4640
4641    let mut code_str = String::new();
4642    if let Some(link_mark) = link_mark {
4643        let href = link_href(link_mark);
4644        code_str.push('[');
4645        render_inline_code(text, &mut code_str);
4646        code_str.push_str("](");
4647        code_str.push_str(href);
4648        code_str.push(')');
4649    } else {
4650        render_inline_code(text, &mut code_str);
4651    }
4652
4653    // Build wrappers (outermost first) for span-attr and bracketed-span runs,
4654    // walking marks in order so the round-trip preserves mark ordering.
4655    let mut wrappers: Vec<(String, String)> = Vec::new();
4656    let mut i = 0;
4657    while i < marks.len() {
4658        match marks[i].mark_type.as_str() {
4659            "textColor" | "backgroundColor" | "subsup" => {
4660                let start = i;
4661                while i < marks.len() && is_span_attr_mark(&marks[i].mark_type) {
4662                    i += 1;
4663                }
4664                emit_span_attr_wrappers(&marks[start..i], &mut wrappers);
4665            }
4666            "underline" | "annotation" => {
4667                let start = i;
4668                while i < marks.len() && is_bracketed_span_mark(&marks[i].mark_type) {
4669                    i += 1;
4670                }
4671                emit_bracketed_wrappers(&marks[start..i], &mut wrappers);
4672            }
4673            _ => {
4674                i += 1;
4675            }
4676        }
4677    }
4678
4679    // Apply wrappers from innermost (last) to outermost (first).
4680    let mut result = code_str;
4681    for (open, close) in wrappers.iter().rev() {
4682        result.insert_str(0, open);
4683        result.push_str(close);
4684    }
4685    output.push_str(&result);
4686}
4687
4688/// Collects `:span` attribute fragments (color, bg, sub/sup) for a single mark.
4689fn collect_span_attr(mark: &AdfMark, attrs: &mut Vec<String>) {
4690    match mark.mark_type.as_str() {
4691        "textColor" => {
4692            if let Some(c) = mark
4693                .attrs
4694                .as_ref()
4695                .and_then(|a| a.get("color"))
4696                .and_then(serde_json::Value::as_str)
4697            {
4698                attrs.push(format!("color={c}"));
4699            }
4700        }
4701        "backgroundColor" => {
4702            if let Some(c) = mark
4703                .attrs
4704                .as_ref()
4705                .and_then(|a| a.get("color"))
4706                .and_then(serde_json::Value::as_str)
4707            {
4708                attrs.push(format!("bg={c}"));
4709            }
4710        }
4711        "subsup" => {
4712            if let Some(kind) = mark
4713                .attrs
4714                .as_ref()
4715                .and_then(|a| a.get("type"))
4716                .and_then(serde_json::Value::as_str)
4717            {
4718                attrs.push(kind.to_string());
4719            }
4720        }
4721        _ => {}
4722    }
4723}
4724
4725/// Collects bracketed-span attribute fragments for an `underline` or `annotation` mark.
4726fn collect_bracketed_attr(mark: &AdfMark, attrs: &mut Vec<String>) {
4727    match mark.mark_type.as_str() {
4728        "underline" => attrs.push("underline".to_string()),
4729        "annotation" => {
4730            if let Some(ref a) = mark.attrs {
4731                if let Some(id) = a.get("id").and_then(serde_json::Value::as_str) {
4732                    let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4733                    attrs.push(format!("annotation-id=\"{escaped}\""));
4734                }
4735                if let Some(at) = a.get("annotationType").and_then(serde_json::Value::as_str) {
4736                    attrs.push(format!("annotation-type={at}"));
4737                }
4738            }
4739        }
4740        _ => {}
4741    }
4742}
4743
4744fn is_span_attr_mark(mark_type: &str) -> bool {
4745    matches!(mark_type, "textColor" | "backgroundColor" | "subsup")
4746}
4747
4748fn is_bracketed_span_mark(mark_type: &str) -> bool {
4749    matches!(mark_type, "underline" | "annotation")
4750}
4751
4752/// Canonical ordering for span-attr marks, matching the order in which the
4753/// `:span` directive parser reads attributes (`color`, then `bg`, then
4754/// `sub`/`sup`).
4755fn span_attr_order(mark_type: &str) -> u8 {
4756    match mark_type {
4757        "textColor" => 0,
4758        "backgroundColor" => 1,
4759        "subsup" => 2,
4760        _ => u8::MAX,
4761    }
4762}
4763
4764/// Returns `true` if the run of span-attr marks is in the canonical order the
4765/// `:span` parser would produce.  A canonical run can be merged into one
4766/// `:span[...]{...}` wrapper; a non-canonical run must be split into one
4767/// nested wrapper per mark so the ordering survives the round-trip.
4768fn span_run_is_canonical(run: &[AdfMark]) -> bool {
4769    let mut prev = 0;
4770    for m in run {
4771        let order = span_attr_order(&m.mark_type);
4772        if order == u8::MAX || order < prev {
4773            return false;
4774        }
4775        prev = order;
4776    }
4777    true
4778}
4779
4780/// Returns `true` if the run of `underline`/`annotation` marks is in the
4781/// canonical order the bracketed-span parser produces (`underline` first,
4782/// followed by annotations).  A canonical run can be merged into one
4783/// `[...]{underline annotation-id=...}` wrapper.
4784fn bracketed_run_is_canonical(run: &[AdfMark]) -> bool {
4785    let mut seen_annotation = false;
4786    for m in run {
4787        match m.mark_type.as_str() {
4788            "underline" => {
4789                if seen_annotation {
4790                    return false;
4791                }
4792            }
4793            "annotation" => seen_annotation = true,
4794            _ => return false,
4795        }
4796    }
4797    true
4798}
4799
4800/// Emits one or more `:span[...]{...}` wrappers for a run of span-attr marks.
4801/// Canonical-order runs collapse into a single wrapper; non-canonical runs
4802/// emit one wrapper per mark so the order round-trips.
4803fn emit_span_attr_wrappers(run: &[AdfMark], wrappers: &mut Vec<(String, String)>) {
4804    if span_run_is_canonical(run) {
4805        let mut attrs = Vec::new();
4806        for m in run {
4807            collect_span_attr(m, &mut attrs);
4808        }
4809        wrappers.push((":span[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4810        return;
4811    }
4812    for m in run {
4813        let mut attrs = Vec::new();
4814        collect_span_attr(m, &mut attrs);
4815        wrappers.push((":span[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4816    }
4817}
4818
4819/// Emits one or more `[...]{...}` wrappers for a run of `underline`/`annotation`
4820/// marks.  Canonical-order runs collapse into a single wrapper; non-canonical
4821/// runs emit one wrapper per mark so the order round-trips.
4822fn emit_bracketed_wrappers(run: &[AdfMark], wrappers: &mut Vec<(String, String)>) {
4823    if bracketed_run_is_canonical(run) {
4824        let mut attrs = Vec::new();
4825        for m in run {
4826            collect_bracketed_attr(m, &mut attrs);
4827        }
4828        wrappers.push(("[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4829        return;
4830    }
4831    for m in run {
4832        let mut attrs = Vec::new();
4833        collect_bracketed_attr(m, &mut attrs);
4834        wrappers.push(("[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4835    }
4836}
4837
4838/// Extracts the href from a link mark.
4839fn link_href(mark: &AdfMark) -> &str {
4840    mark.attrs
4841        .as_ref()
4842        .and_then(|a| a.get("href"))
4843        .and_then(serde_json::Value::as_str)
4844        .unwrap_or("")
4845}
4846
4847#[cfg(test)]
4848#[allow(clippy::unwrap_used, clippy::expect_used)]
4849mod tests {
4850    use super::*;
4851
4852    // ── markdown_to_adf tests ───────────────────────────────────────
4853
4854    #[test]
4855    fn paragraph() {
4856        let doc = markdown_to_adf("Hello world").unwrap();
4857        assert_eq!(doc.content.len(), 1);
4858        assert_eq!(doc.content[0].node_type, "paragraph");
4859    }
4860
4861    #[test]
4862    fn heading_levels() {
4863        for level in 1..=6 {
4864            let hashes = "#".repeat(level);
4865            let md = format!("{hashes} Title");
4866            let doc = markdown_to_adf(&md).unwrap();
4867            assert_eq!(doc.content[0].node_type, "heading");
4868            let attrs = doc.content[0].attrs.as_ref().unwrap();
4869            assert_eq!(attrs["level"], level as u64);
4870        }
4871    }
4872
4873    #[test]
4874    fn code_block() {
4875        let md = "```rust\nfn main() {}\n```";
4876        let doc = markdown_to_adf(md).unwrap();
4877        assert_eq!(doc.content[0].node_type, "codeBlock");
4878        let attrs = doc.content[0].attrs.as_ref().unwrap();
4879        assert_eq!(attrs["language"], "rust");
4880    }
4881
4882    #[test]
4883    fn code_block_no_language() {
4884        let md = "```\nsome code\n```";
4885        let doc = markdown_to_adf(md).unwrap();
4886        assert_eq!(doc.content[0].node_type, "codeBlock");
4887        assert!(doc.content[0].attrs.is_none());
4888    }
4889
4890    #[test]
4891    fn code_block_empty_language() {
4892        let md = "```\"\"\nsome code\n```";
4893        let doc = markdown_to_adf(md).unwrap();
4894        assert_eq!(doc.content[0].node_type, "codeBlock");
4895        let attrs = doc.content[0].attrs.as_ref().unwrap();
4896        assert_eq!(attrs["language"], "");
4897    }
4898
4899    #[test]
4900    fn horizontal_rule() {
4901        let doc = markdown_to_adf("---").unwrap();
4902        assert_eq!(doc.content[0].node_type, "rule");
4903    }
4904
4905    #[test]
4906    fn horizontal_rule_stars() {
4907        let doc = markdown_to_adf("***").unwrap();
4908        assert_eq!(doc.content[0].node_type, "rule");
4909    }
4910
4911    #[test]
4912    fn blockquote() {
4913        let md = "> This is a quote\n> Second line";
4914        let doc = markdown_to_adf(md).unwrap();
4915        assert_eq!(doc.content[0].node_type, "blockquote");
4916    }
4917
4918    #[test]
4919    fn bullet_list() {
4920        let md = "- Item 1\n- Item 2\n- Item 3";
4921        let doc = markdown_to_adf(md).unwrap();
4922        assert_eq!(doc.content[0].node_type, "bulletList");
4923        let items = doc.content[0].content.as_ref().unwrap();
4924        assert_eq!(items.len(), 3);
4925    }
4926
4927    #[test]
4928    fn ordered_list() {
4929        let md = "1. First\n2. Second\n3. Third";
4930        let doc = markdown_to_adf(md).unwrap();
4931        assert_eq!(doc.content[0].node_type, "orderedList");
4932        let items = doc.content[0].content.as_ref().unwrap();
4933        assert_eq!(items.len(), 3);
4934    }
4935
4936    #[test]
4937    fn task_list() {
4938        let md = "- [ ] Todo item\n- [x] Done item";
4939        let doc = markdown_to_adf(md).unwrap();
4940        assert_eq!(doc.content[0].node_type, "taskList");
4941        let items = doc.content[0].content.as_ref().unwrap();
4942        assert_eq!(items.len(), 2);
4943        assert_eq!(items[0].node_type, "taskItem");
4944        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
4945        assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
4946    }
4947
4948    #[test]
4949    fn task_list_uppercase_x() {
4950        let md = "- [X] Done item";
4951        let doc = markdown_to_adf(md).unwrap();
4952        assert_eq!(doc.content[0].node_type, "taskList");
4953        let item = &doc.content[0].content.as_ref().unwrap()[0];
4954        assert_eq!(item.attrs.as_ref().unwrap()["state"], "DONE");
4955    }
4956
4957    /// Issue #548: an empty task marker (no trailing space) must still be
4958    /// parsed as a `taskList` rather than a `bulletList` with `[ ]` text.
4959    #[test]
4960    fn task_list_empty_todo_no_trailing_space() {
4961        let md = "- [ ]";
4962        let doc = markdown_to_adf(md).unwrap();
4963        assert_eq!(doc.content[0].node_type, "taskList");
4964        let items = doc.content[0].content.as_ref().unwrap();
4965        assert_eq!(items.len(), 1);
4966        assert_eq!(items[0].node_type, "taskItem");
4967        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
4968        assert!(items[0].content.is_none());
4969    }
4970
4971    /// Issue #548: likewise for a done checkbox with no body.
4972    #[test]
4973    fn task_list_empty_done_no_trailing_space() {
4974        let md = "- [x]\n- [X]";
4975        let doc = markdown_to_adf(md).unwrap();
4976        assert_eq!(doc.content[0].node_type, "taskList");
4977        let items = doc.content[0].content.as_ref().unwrap();
4978        assert_eq!(items.len(), 2);
4979        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DONE");
4980        assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
4981    }
4982
4983    /// Issue #548: the body of `- [ ] text` must not have a spurious leading
4984    /// space introduced by relaxing the trailing-space requirement.
4985    #[test]
4986    fn task_list_body_has_no_leading_space() {
4987        let md = "- [ ] Buy groceries";
4988        let doc = markdown_to_adf(md).unwrap();
4989        let item = &doc.content[0].content.as_ref().unwrap()[0];
4990        let text = item.content.as_ref().unwrap()[0].text.as_deref().unwrap();
4991        assert_eq!(text, "Buy groceries");
4992    }
4993
4994    /// Issue #548: round-trip from ADF with empty taskItems should preserve
4995    /// the `taskList` structure even if trailing spaces are stripped from the
4996    /// intermediate markdown (as many editors do).
4997    #[test]
4998    fn round_trip_empty_task_items_stripped_trailing_spaces() {
4999        let json = r#"{
5000            "version": 1,
5001            "type": "doc",
5002            "content": [{
5003                "type": "taskList",
5004                "attrs": {"localId": "abc"},
5005                "content": [
5006                    {"type": "taskItem", "attrs": {"localId": "def", "state": "TODO"}},
5007                    {"type": "taskItem", "attrs": {"localId": "ghi", "state": "DONE"}}
5008                ]
5009            }]
5010        }"#;
5011        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5012        let md = adf_to_markdown(&doc).unwrap();
5013        let stripped: String = md
5014            .lines()
5015            .map(|l| l.trim_end())
5016            .collect::<Vec<_>>()
5017            .join("\n");
5018        let parsed = markdown_to_adf(&stripped).unwrap();
5019        assert_eq!(parsed.content[0].node_type, "taskList");
5020        let items = parsed.content[0].content.as_ref().unwrap();
5021        assert_eq!(items.len(), 2);
5022        assert_eq!(items[0].node_type, "taskItem");
5023        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5024        assert_eq!(items[1].node_type, "taskItem");
5025        assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5026    }
5027
5028    #[test]
5029    fn try_parse_task_marker_accepts_bare_checkbox() {
5030        assert_eq!(try_parse_task_marker("[ ]"), Some(("TODO", "")));
5031        assert_eq!(try_parse_task_marker("[x]"), Some(("DONE", "")));
5032        assert_eq!(try_parse_task_marker("[X]"), Some(("DONE", "")));
5033        assert_eq!(try_parse_task_marker("[ ] foo"), Some(("TODO", "foo")));
5034        assert_eq!(try_parse_task_marker("[x] foo"), Some(("DONE", "foo")));
5035        assert_eq!(try_parse_task_marker("[ ]foo"), None);
5036        assert_eq!(try_parse_task_marker("[x]foo"), None);
5037        assert_eq!(try_parse_task_marker("[y] foo"), None);
5038    }
5039
5040    #[test]
5041    fn starts_with_task_marker_matches_parser() {
5042        // Anything `try_parse_task_marker` recognises must also be flagged
5043        // here so the renderer escapes it.
5044        assert!(starts_with_task_marker("[ ]"));
5045        assert!(starts_with_task_marker("[x]"));
5046        assert!(starts_with_task_marker("[X]"));
5047        assert!(starts_with_task_marker("[ ] foo"));
5048        assert!(starts_with_task_marker("[x] foo\n"));
5049        assert!(starts_with_task_marker("[ ]\n"));
5050        // No collision when the bracket is followed by non-whitespace.
5051        assert!(!starts_with_task_marker("[ ]foo"));
5052        assert!(!starts_with_task_marker("[y] foo"));
5053        assert!(!starts_with_task_marker("foo [ ] bar"));
5054        assert!(!starts_with_task_marker(""));
5055    }
5056
5057    /// Issue #548: a `bulletList` whose item starts with literal `[ ]` text
5058    /// must round-trip through markdown without being promoted to a
5059    /// `taskList`.
5060    #[test]
5061    fn round_trip_bullet_list_with_literal_checkbox_text() {
5062        let json = r#"{
5063            "version": 1,
5064            "type": "doc",
5065            "content": [{
5066                "type": "bulletList",
5067                "content": [{
5068                    "type": "listItem",
5069                    "content": [{
5070                        "type": "paragraph",
5071                        "content": [
5072                            {"type": "text", "text": "[ ] Review the "},
5073                            {"type": "text", "text": "config.yaml", "marks": [{"type": "code"}]},
5074                            {"type": "text", "text": " file"}
5075                        ]
5076                    }]
5077                }]
5078            }]
5079        }"#;
5080        let original: AdfDocument = serde_json::from_str(json).unwrap();
5081        let md = adf_to_markdown(&original).unwrap();
5082        // Renderer must escape the leading bracket.
5083        assert!(
5084            md.contains(r"- \[ ] Review the "),
5085            "rendered markdown: {md:?}"
5086        );
5087        let parsed = markdown_to_adf(&md).unwrap();
5088        assert_eq!(parsed.content[0].node_type, "bulletList");
5089        let item = &parsed.content[0].content.as_ref().unwrap()[0];
5090        assert_eq!(item.node_type, "listItem");
5091        let para = &item.content.as_ref().unwrap()[0];
5092        assert_eq!(para.node_type, "paragraph");
5093        let text_nodes = para.content.as_ref().unwrap();
5094        assert_eq!(text_nodes[0].text.as_deref().unwrap(), "[ ] Review the ");
5095        assert_eq!(text_nodes[1].text.as_deref().unwrap(), "config.yaml");
5096        assert_eq!(text_nodes[2].text.as_deref().unwrap(), " file");
5097    }
5098
5099    /// Issue #548: the same problem with a `[x]` marker.
5100    #[test]
5101    fn round_trip_bullet_list_with_literal_done_checkbox_text() {
5102        let json = r#"{
5103            "version": 1,
5104            "type": "doc",
5105            "content": [{
5106                "type": "bulletList",
5107                "content": [{
5108                    "type": "listItem",
5109                    "content": [{
5110                        "type": "paragraph",
5111                        "content": [{"type": "text", "text": "[x] not actually done"}]
5112                    }]
5113                }]
5114            }]
5115        }"#;
5116        let original: AdfDocument = serde_json::from_str(json).unwrap();
5117        let md = adf_to_markdown(&original).unwrap();
5118        assert!(md.contains(r"- \[x] "), "rendered markdown: {md:?}");
5119        let parsed = markdown_to_adf(&md).unwrap();
5120        assert_eq!(parsed.content[0].node_type, "bulletList");
5121        let item = &parsed.content[0].content.as_ref().unwrap()[0];
5122        let para = &item.content.as_ref().unwrap()[0];
5123        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5124        assert_eq!(text, "[x] not actually done");
5125    }
5126
5127    /// Issue #548: `bulletList` item whose entire content is literal `[ ]`.
5128    #[test]
5129    fn round_trip_bullet_list_with_bare_literal_checkbox() {
5130        let json = r#"{
5131            "version": 1,
5132            "type": "doc",
5133            "content": [{
5134                "type": "bulletList",
5135                "content": [{
5136                    "type": "listItem",
5137                    "content": [{
5138                        "type": "paragraph",
5139                        "content": [{"type": "text", "text": "[ ]"}]
5140                    }]
5141                }]
5142            }]
5143        }"#;
5144        let original: AdfDocument = serde_json::from_str(json).unwrap();
5145        let md = adf_to_markdown(&original).unwrap();
5146        let parsed = markdown_to_adf(&md).unwrap();
5147        assert_eq!(parsed.content[0].node_type, "bulletList");
5148        let item = &parsed.content[0].content.as_ref().unwrap()[0];
5149        let para = &item.content.as_ref().unwrap()[0];
5150        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5151        assert_eq!(text, "[ ]");
5152    }
5153
5154    /// Issue #548: a `bulletList` with a non-task `[?]` prefix should not be
5155    /// escaped — that would just produce noise.
5156    #[test]
5157    fn bullet_list_non_task_bracket_text_not_escaped() {
5158        let json = r#"{
5159            "version": 1,
5160            "type": "doc",
5161            "content": [{
5162                "type": "bulletList",
5163                "content": [{
5164                    "type": "listItem",
5165                    "content": [{
5166                        "type": "paragraph",
5167                        "content": [{"type": "text", "text": "[?] unsure"}]
5168                    }]
5169                }]
5170            }]
5171        }"#;
5172        let original: AdfDocument = serde_json::from_str(json).unwrap();
5173        let md = adf_to_markdown(&original).unwrap();
5174        assert!(!md.contains(r"\["), "should not escape: {md:?}");
5175        assert!(md.contains("- [?] unsure"), "rendered: {md:?}");
5176    }
5177
5178    /// Issue #548: nested `bulletList` items inside another `bulletList`
5179    /// must also have their literal `[ ]` text escaped.
5180    #[test]
5181    fn round_trip_nested_bullet_list_with_literal_checkbox_text() {
5182        let json = r#"{
5183            "version": 1,
5184            "type": "doc",
5185            "content": [{
5186                "type": "bulletList",
5187                "content": [{
5188                    "type": "listItem",
5189                    "content": [
5190                        {"type": "paragraph", "content": [{"type": "text", "text": "outer"}]},
5191                        {"type": "bulletList", "content": [{
5192                            "type": "listItem",
5193                            "content": [{
5194                                "type": "paragraph",
5195                                "content": [{"type": "text", "text": "[ ] inner literal"}]
5196                            }]
5197                        }]}
5198                    ]
5199                }]
5200            }]
5201        }"#;
5202        let original: AdfDocument = serde_json::from_str(json).unwrap();
5203        let md = adf_to_markdown(&original).unwrap();
5204        let parsed = markdown_to_adf(&md).unwrap();
5205        let outer = &parsed.content[0];
5206        assert_eq!(outer.node_type, "bulletList");
5207        let outer_item = &outer.content.as_ref().unwrap()[0];
5208        let inner_list = &outer_item.content.as_ref().unwrap()[1];
5209        assert_eq!(inner_list.node_type, "bulletList");
5210        let inner_item = &inner_list.content.as_ref().unwrap()[0];
5211        assert_eq!(inner_item.node_type, "listItem");
5212        let para = &inner_item.content.as_ref().unwrap()[0];
5213        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5214        assert_eq!(text, "[ ] inner literal");
5215    }
5216
5217    #[test]
5218    fn adf_task_list_to_markdown() {
5219        let doc = AdfDocument {
5220            version: 1,
5221            doc_type: "doc".to_string(),
5222            content: vec![AdfNode::task_list(vec![
5223                AdfNode::task_item(
5224                    "TODO",
5225                    vec![AdfNode::paragraph(vec![AdfNode::text("Todo")])],
5226                ),
5227                AdfNode::task_item(
5228                    "DONE",
5229                    vec![AdfNode::paragraph(vec![AdfNode::text("Done")])],
5230                ),
5231            ])],
5232        };
5233        let md = adf_to_markdown(&doc).unwrap();
5234        assert!(md.contains("- [ ] Todo"));
5235        assert!(md.contains("- [x] Done"));
5236    }
5237
5238    #[test]
5239    fn round_trip_task_list() {
5240        let md = "- [ ] Todo item\n- [x] Done item\n";
5241        let doc = markdown_to_adf(md).unwrap();
5242        let result = adf_to_markdown(&doc).unwrap();
5243        assert!(result.contains("- [ ] Todo item"));
5244        assert!(result.contains("- [x] Done item"));
5245    }
5246
5247    /// Issue #408: taskItem content with inline nodes directly (no paragraph wrapper).
5248    #[test]
5249    fn adf_task_item_unwrapped_inline_content() {
5250        // Real Confluence ADF: taskItem contains text nodes directly, no paragraph.
5251        let json = r#"{
5252            "version": 1,
5253            "type": "doc",
5254            "content": [{
5255                "type": "taskList",
5256                "attrs": {"localId": "list-001"},
5257                "content": [{
5258                    "type": "taskItem",
5259                    "attrs": {"localId": "task-001", "state": "TODO"},
5260                    "content": [{"type": "text", "text": "Do something"}]
5261                }]
5262            }]
5263        }"#;
5264        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5265        let md = adf_to_markdown(&doc).unwrap();
5266        assert!(md.contains("- [ ] Do something"), "got: {md}");
5267        assert!(!md.contains("adf-unsupported"), "got: {md}");
5268    }
5269
5270    /// Issue #408: multiple taskItems with unwrapped inline content.
5271    #[test]
5272    fn adf_task_list_multiple_unwrapped_items() {
5273        let json = r#"{
5274            "version": 1,
5275            "type": "doc",
5276            "content": [{
5277                "type": "taskList",
5278                "attrs": {"localId": "list-001"},
5279                "content": [
5280                    {
5281                        "type": "taskItem",
5282                        "attrs": {"localId": "task-001", "state": "TODO"},
5283                        "content": [{"type": "text", "text": "First task"}]
5284                    },
5285                    {
5286                        "type": "taskItem",
5287                        "attrs": {"localId": "task-002", "state": "DONE"},
5288                        "content": [{"type": "text", "text": "Second task"}]
5289                    }
5290                ]
5291            }]
5292        }"#;
5293        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5294        let md = adf_to_markdown(&doc).unwrap();
5295        assert!(md.contains("- [ ] First task"), "got: {md}");
5296        assert!(md.contains("- [x] Second task"), "got: {md}");
5297        assert!(!md.contains("adf-unsupported"), "got: {md}");
5298    }
5299
5300    /// Issue #408: unwrapped inline content with marks (bold text).
5301    #[test]
5302    fn adf_task_item_unwrapped_inline_with_marks() {
5303        let json = r#"{
5304            "version": 1,
5305            "type": "doc",
5306            "content": [{
5307                "type": "taskList",
5308                "attrs": {"localId": "list-001"},
5309                "content": [{
5310                    "type": "taskItem",
5311                    "attrs": {"localId": "task-001", "state": "TODO"},
5312                    "content": [
5313                        {"type": "text", "text": "Buy "},
5314                        {"type": "text", "text": "groceries", "marks": [{"type": "strong"}]},
5315                        {"type": "text", "text": " today"}
5316                    ]
5317                }]
5318            }]
5319        }"#;
5320        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5321        let md = adf_to_markdown(&doc).unwrap();
5322        assert!(md.contains("- [ ] Buy **groceries** today"), "got: {md}");
5323    }
5324
5325    /// Issue #408: taskItem localId is preserved for unwrapped inline content.
5326    #[test]
5327    fn adf_task_item_unwrapped_preserves_local_id() {
5328        let json = r#"{
5329            "version": 1,
5330            "type": "doc",
5331            "content": [{
5332                "type": "taskList",
5333                "attrs": {"localId": "list-001"},
5334                "content": [{
5335                    "type": "taskItem",
5336                    "attrs": {"localId": "task-001", "state": "TODO"},
5337                    "content": [{"type": "text", "text": "Do something"}]
5338                }]
5339            }]
5340        }"#;
5341        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5342        let md = adf_to_markdown(&doc).unwrap();
5343        assert!(md.contains("{localId=task-001}"), "got: {md}");
5344        assert!(md.contains("{localId=list-001}"), "got: {md}");
5345    }
5346
5347    /// Issue #408: round-trip from Confluence ADF with unwrapped taskItem content.
5348    #[test]
5349    fn round_trip_task_list_unwrapped_inline() {
5350        let json = r#"{
5351            "version": 1,
5352            "type": "doc",
5353            "content": [{
5354                "type": "taskList",
5355                "attrs": {"localId": "list-001"},
5356                "content": [
5357                    {
5358                        "type": "taskItem",
5359                        "attrs": {"localId": "task-001", "state": "TODO"},
5360                        "content": [{"type": "text", "text": "Do something"}]
5361                    },
5362                    {
5363                        "type": "taskItem",
5364                        "attrs": {"localId": "task-002", "state": "DONE"},
5365                        "content": [{"type": "text", "text": "Already done"}]
5366                    }
5367                ]
5368            }]
5369        }"#;
5370        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5371        let md = adf_to_markdown(&doc).unwrap();
5372
5373        // Round-trip: markdown back to ADF
5374        let doc2 = markdown_to_adf(&md).unwrap();
5375        assert_eq!(doc2.content[0].node_type, "taskList");
5376
5377        let items = doc2.content[0].content.as_ref().unwrap();
5378        assert_eq!(items.len(), 2);
5379        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5380        assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5381
5382        // localIds preserved
5383        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "task-001");
5384        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "task-002");
5385        assert_eq!(
5386            doc2.content[0].attrs.as_ref().unwrap()["localId"],
5387            "list-001"
5388        );
5389    }
5390
5391    /// Issue #408: taskItem with inline content followed by a nested block (sub-list).
5392    #[test]
5393    fn adf_task_item_unwrapped_inline_then_block() {
5394        let json = r#"{
5395            "version": 1,
5396            "type": "doc",
5397            "content": [{
5398                "type": "taskList",
5399                "attrs": {"localId": "list-001"},
5400                "content": [{
5401                    "type": "taskItem",
5402                    "attrs": {"localId": "task-001", "state": "TODO"},
5403                    "content": [
5404                        {"type": "text", "text": "Parent task"},
5405                        {
5406                            "type": "bulletList",
5407                            "content": [{
5408                                "type": "listItem",
5409                                "content": [{
5410                                    "type": "paragraph",
5411                                    "content": [{"type": "text", "text": "sub-item"}]
5412                                }]
5413                            }]
5414                        }
5415                    ]
5416                }]
5417            }]
5418        }"#;
5419        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5420        let md = adf_to_markdown(&doc).unwrap();
5421        assert!(md.contains("- [ ] Parent task"), "got: {md}");
5422        assert!(md.contains("  - sub-item"), "got: {md}");
5423        assert!(!md.contains("adf-unsupported"), "got: {md}");
5424    }
5425
5426    /// Issue #408: taskItem with empty content array renders without panic.
5427    #[test]
5428    fn adf_task_item_empty_content() {
5429        let json = r#"{
5430            "version": 1,
5431            "type": "doc",
5432            "content": [{
5433                "type": "taskList",
5434                "attrs": {"localId": "list-001"},
5435                "content": [{
5436                    "type": "taskItem",
5437                    "attrs": {"localId": "task-001", "state": "TODO"},
5438                    "content": []
5439                }]
5440            }]
5441        }"#;
5442        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5443        let md = adf_to_markdown(&doc).unwrap();
5444        assert!(md.contains("- [ ] "), "got: {md}");
5445        assert!(!md.contains("adf-unsupported"), "got: {md}");
5446    }
5447
5448    /// Issue #489: nested taskItem inside taskItem.content renders as indented
5449    /// task items instead of corrupting the surrounding taskList.
5450    #[test]
5451    fn adf_nested_task_item_renders_without_corruption() {
5452        let json = r#"{
5453            "type": "doc",
5454            "version": 1,
5455            "content": [{
5456                "type": "taskList",
5457                "attrs": {"localId": ""},
5458                "content": [
5459                    {
5460                        "type": "taskItem",
5461                        "attrs": {"localId": "aabbccdd-1234-5678-abcd-aabbccdd1234", "state": "TODO"},
5462                        "content": [{"type": "text", "text": "Normal task"}]
5463                    },
5464                    {
5465                        "type": "taskItem",
5466                        "attrs": {"localId": ""},
5467                        "content": [
5468                            {
5469                                "type": "taskItem",
5470                                "attrs": {"localId": "bbccddee-2345-6789-bcde-bbccddee2345", "state": "TODO"},
5471                                "content": [{"type": "text", "text": "Nested task one"}]
5472                            },
5473                            {
5474                                "type": "taskItem",
5475                                "attrs": {"localId": "ccddee11-3456-7890-cdef-ccddee113456", "state": "DONE"},
5476                                "content": [{"type": "text", "text": "Nested task two"}]
5477                            }
5478                        ]
5479                    }
5480                ]
5481            }]
5482        }"#;
5483        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5484        let md = adf_to_markdown(&doc).unwrap();
5485        // Normal task preserved
5486        assert!(md.contains("- [ ] Normal task"), "got: {md}");
5487        // Nested tasks rendered as indented task items, not adf-unsupported
5488        assert!(!md.contains("adf-unsupported"), "got: {md}");
5489        assert!(md.contains("  - [ ] Nested task one"), "got: {md}");
5490        assert!(md.contains("  - [x] Nested task two"), "got: {md}");
5491    }
5492
5493    /// Issue #489: round-trip of nested taskItem preserves data.
5494    #[test]
5495    fn round_trip_nested_task_item() {
5496        let json = r#"{
5497            "type": "doc",
5498            "version": 1,
5499            "content": [{
5500                "type": "taskList",
5501                "attrs": {"localId": ""},
5502                "content": [
5503                    {
5504                        "type": "taskItem",
5505                        "attrs": {"localId": "task-001", "state": "TODO"},
5506                        "content": [{"type": "text", "text": "Normal task"}]
5507                    },
5508                    {
5509                        "type": "taskItem",
5510                        "attrs": {"localId": ""},
5511                        "content": [
5512                            {
5513                                "type": "taskItem",
5514                                "attrs": {"localId": "task-002", "state": "TODO"},
5515                                "content": [{"type": "text", "text": "Nested one"}]
5516                            },
5517                            {
5518                                "type": "taskItem",
5519                                "attrs": {"localId": "task-003", "state": "DONE"},
5520                                "content": [{"type": "text", "text": "Nested two"}]
5521                            }
5522                        ]
5523                    }
5524                ]
5525            }]
5526        }"#;
5527        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5528        let md = adf_to_markdown(&doc).unwrap();
5529        let doc2 = markdown_to_adf(&md).unwrap();
5530
5531        // Top-level structure: taskList with 2 items
5532        assert_eq!(doc2.content[0].node_type, "taskList");
5533        let items = doc2.content[0].content.as_ref().unwrap();
5534        assert_eq!(items.len(), 2, "expected 2 top-level items, got: {items:?}");
5535
5536        // First item: normal task preserved
5537        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5538        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "task-001");
5539        let first_content = items[0].content.as_ref().unwrap();
5540        assert_eq!(first_content[0].text.as_deref(), Some("Normal task"));
5541
5542        // Second item: container taskItem — no spurious `state` attr
5543        let container = &items[1];
5544        assert_eq!(container.node_type, "taskItem");
5545        let c_attrs = container.attrs.as_ref().unwrap();
5546        assert!(
5547            c_attrs.get("state").is_none(),
5548            "container should have no state attr, got: {c_attrs:?}"
5549        );
5550
5551        // Children are bare taskItems, NOT wrapped in a taskList
5552        let container_content = container.content.as_ref().unwrap();
5553        assert_eq!(
5554            container_content.len(),
5555            2,
5556            "expected 2 bare taskItem children"
5557        );
5558        assert_eq!(container_content[0].node_type, "taskItem");
5559        assert_eq!(
5560            container_content[0].attrs.as_ref().unwrap()["state"],
5561            "TODO"
5562        );
5563        assert_eq!(
5564            container_content[0].attrs.as_ref().unwrap()["localId"],
5565            "task-002"
5566        );
5567        assert_eq!(container_content[1].node_type, "taskItem");
5568        assert_eq!(
5569            container_content[1].attrs.as_ref().unwrap()["state"],
5570            "DONE"
5571        );
5572        assert_eq!(
5573            container_content[1].attrs.as_ref().unwrap()["localId"],
5574            "task-003"
5575        );
5576    }
5577
5578    /// Issue #489: nested taskItem with localIds on both container and children.
5579    #[test]
5580    fn adf_nested_task_item_preserves_local_ids() {
5581        let json = r#"{
5582            "type": "doc",
5583            "version": 1,
5584            "content": [{
5585                "type": "taskList",
5586                "attrs": {"localId": "list-001"},
5587                "content": [{
5588                    "type": "taskItem",
5589                    "attrs": {"localId": "container-001", "state": "TODO"},
5590                    "content": [{
5591                        "type": "taskItem",
5592                        "attrs": {"localId": "child-001", "state": "DONE"},
5593                        "content": [{"type": "text", "text": "Nested child"}]
5594                    }]
5595                }]
5596            }]
5597        }"#;
5598        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5599        let md = adf_to_markdown(&doc).unwrap();
5600        // Container localId is emitted
5601        assert!(
5602            md.contains("localId=container-001"),
5603            "container localId missing: {md}"
5604        );
5605        // Child localId is emitted
5606        assert!(
5607            md.contains("localId=child-001"),
5608            "child localId missing: {md}"
5609        );
5610        assert!(!md.contains("adf-unsupported"), "got: {md}");
5611    }
5612
5613    /// Issue #489: nested taskItem content mixed with a non-taskItem block node.
5614    /// Covers the else branch in the renderer where a child is not a taskItem.
5615    #[test]
5616    fn adf_nested_task_item_mixed_with_block_node() {
5617        let json = r#"{
5618            "type": "doc",
5619            "version": 1,
5620            "content": [{
5621                "type": "taskList",
5622                "attrs": {"localId": ""},
5623                "content": [{
5624                    "type": "taskItem",
5625                    "attrs": {"localId": "", "state": "TODO"},
5626                    "content": [
5627                        {
5628                            "type": "taskItem",
5629                            "attrs": {"localId": "", "state": "TODO"},
5630                            "content": [{"type": "text", "text": "A nested task"}]
5631                        },
5632                        {
5633                            "type": "paragraph",
5634                            "content": [{"type": "text", "text": "Stray paragraph"}]
5635                        }
5636                    ]
5637                }]
5638            }]
5639        }"#;
5640        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5641        let md = adf_to_markdown(&doc).unwrap();
5642        assert!(md.contains("  - [ ] A nested task"), "got: {md}");
5643        assert!(md.contains("  Stray paragraph"), "got: {md}");
5644        assert!(!md.contains("adf-unsupported"), "got: {md}");
5645    }
5646
5647    /// Issue #489: task item with inline text AND indented sub-content.
5648    /// Covers the parser's `Some` branch when appending nested blocks to
5649    /// an existing content vec.
5650    #[test]
5651    fn task_item_with_text_and_nested_sub_content() {
5652        let md = "- [ ] Parent task\n  - [ ] Sub task\n";
5653        let doc = markdown_to_adf(md).unwrap();
5654        assert_eq!(doc.content[0].node_type, "taskList");
5655        let items = doc.content[0].content.as_ref().unwrap();
5656        // Issue #506: the nested taskList is a sibling of the taskItem,
5657        // not a child — matching ADF's canonical structure.
5658        assert_eq!(items.len(), 2, "got: {items:?}");
5659        let parent = &items[0];
5660        assert_eq!(parent.attrs.as_ref().unwrap()["state"], "TODO");
5661        let parent_content = parent.content.as_ref().unwrap();
5662        assert_eq!(parent_content[0].text.as_deref(), Some("Parent task"));
5663        // Second item: nested taskList (sibling)
5664        assert_eq!(items[1].node_type, "taskList");
5665        let nested = items[1].content.as_ref().unwrap();
5666        assert_eq!(nested.len(), 1);
5667        assert_eq!(nested[0].attrs.as_ref().unwrap()["state"], "TODO");
5668    }
5669
5670    /// Issue #489: empty task item with non-taskList sub-content (e.g. a
5671    /// paragraph).  Exercises the `None` branch when the sub-content does
5672    /// not qualify for container-unwrap.
5673    #[test]
5674    fn task_item_empty_with_non_tasklist_sub_content() {
5675        let md = "- [ ] \n  Some paragraph text\n";
5676        let doc = markdown_to_adf(md).unwrap();
5677        assert_eq!(doc.content[0].node_type, "taskList");
5678        let items = doc.content[0].content.as_ref().unwrap();
5679        assert_eq!(items.len(), 1);
5680        let item = &items[0];
5681        assert_eq!(item.attrs.as_ref().unwrap()["state"], "TODO");
5682        let content = item.content.as_ref().unwrap();
5683        // Sub-content is a paragraph (not unwrapped since it's not a taskList)
5684        assert_eq!(content[0].node_type, "paragraph");
5685    }
5686
5687    /// Issue #489: single nested taskItem (edge case — only one child).
5688    #[test]
5689    fn adf_nested_task_item_single_child() {
5690        let json = r#"{
5691            "type": "doc",
5692            "version": 1,
5693            "content": [{
5694                "type": "taskList",
5695                "attrs": {"localId": ""},
5696                "content": [{
5697                    "type": "taskItem",
5698                    "attrs": {"localId": "", "state": "TODO"},
5699                    "content": [{
5700                        "type": "taskItem",
5701                        "attrs": {"localId": "", "state": "DONE"},
5702                        "content": [{"type": "text", "text": "Only child"}]
5703                    }]
5704                }]
5705            }]
5706        }"#;
5707        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5708        let md = adf_to_markdown(&doc).unwrap();
5709        assert!(md.contains("  - [x] Only child"), "got: {md}");
5710        assert!(!md.contains("adf-unsupported"), "got: {md}");
5711    }
5712
5713    /// Issue #506: nested taskList as direct child of outer taskList is
5714    /// rendered indented so it round-trips back as taskList, not taskItem.
5715    #[test]
5716    fn adf_nested_tasklist_sibling_renders_indented() {
5717        let json = r#"{
5718            "version": 1,
5719            "type": "doc",
5720            "content": [{
5721                "type": "taskList",
5722                "attrs": {"localId": ""},
5723                "content": [
5724                    {
5725                        "type": "taskItem",
5726                        "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000001", "state": "TODO"},
5727                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task one"}]}]
5728                    },
5729                    {
5730                        "type": "taskList",
5731                        "attrs": {"localId": ""},
5732                        "content": [{
5733                            "type": "taskItem",
5734                            "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000002", "state": "TODO"},
5735                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "nested sub-task"}]}]
5736                        }]
5737                    },
5738                    {
5739                        "type": "taskItem",
5740                        "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000003", "state": "TODO"},
5741                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task two"}]}]
5742                    }
5743                ]
5744            }]
5745        }"#;
5746        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5747        let md = adf_to_markdown(&doc).unwrap();
5748        // The nested taskList should be indented under the preceding item.
5749        assert!(md.contains("- [ ] parent task one"), "got: {md}");
5750        assert!(md.contains("  - [ ] nested sub-task"), "got: {md}");
5751        assert!(md.contains("- [ ] parent task two"), "got: {md}");
5752    }
5753
5754    /// Issue #506: round-trip preserves nested taskList type.
5755    #[test]
5756    fn round_trip_nested_tasklist_preserves_type() {
5757        let json = r#"{
5758            "version": 1,
5759            "type": "doc",
5760            "content": [{
5761                "type": "taskList",
5762                "attrs": {"localId": ""},
5763                "content": [
5764                    {
5765                        "type": "taskItem",
5766                        "attrs": {"localId": "", "state": "TODO"},
5767                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task one"}]}]
5768                    },
5769                    {
5770                        "type": "taskList",
5771                        "attrs": {"localId": ""},
5772                        "content": [{
5773                            "type": "taskItem",
5774                            "attrs": {"localId": "", "state": "TODO"},
5775                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "nested sub-task"}]}]
5776                        }]
5777                    },
5778                    {
5779                        "type": "taskItem",
5780                        "attrs": {"localId": "", "state": "TODO"},
5781                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task two"}]}]
5782                    }
5783                ]
5784            }]
5785        }"#;
5786        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5787        let md = adf_to_markdown(&doc).unwrap();
5788        let rt_doc = markdown_to_adf(&md).unwrap();
5789        // The outer taskList should still be present.
5790        assert_eq!(rt_doc.content[0].node_type, "taskList");
5791        let items = rt_doc.content[0].content.as_ref().unwrap();
5792        // The nested taskList is a sibling of the taskItem nodes,
5793        // matching the original ADF structure (issue #506).
5794        assert_eq!(items.len(), 3, "got: {items:?}");
5795        assert_eq!(items[0].node_type, "taskItem");
5796        assert_eq!(
5797            items[1].node_type, "taskList",
5798            "nested taskList should survive round-trip"
5799        );
5800        assert_eq!(items[2].node_type, "taskItem");
5801        let nested_items = items[1].content.as_ref().unwrap();
5802        assert_eq!(nested_items[0].attrs.as_ref().unwrap()["state"], "TODO");
5803    }
5804
5805    /// Issue #506: nested taskList with DONE state preserves checkbox.
5806    #[test]
5807    fn adf_nested_tasklist_done_state() {
5808        let json = r#"{
5809            "version": 1,
5810            "type": "doc",
5811            "content": [{
5812                "type": "taskList",
5813                "attrs": {"localId": ""},
5814                "content": [
5815                    {
5816                        "type": "taskItem",
5817                        "attrs": {"localId": "", "state": "TODO"},
5818                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent"}]}]
5819                    },
5820                    {
5821                        "type": "taskList",
5822                        "attrs": {"localId": ""},
5823                        "content": [{
5824                            "type": "taskItem",
5825                            "attrs": {"localId": "", "state": "DONE"},
5826                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "done child"}]}]
5827                        }]
5828                    }
5829                ]
5830            }]
5831        }"#;
5832        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5833        let md = adf_to_markdown(&doc).unwrap();
5834        assert!(md.contains("  - [x] done child"), "got: {md}");
5835        // Round-trip preserves DONE state — nested taskList is a sibling.
5836        let rt_doc = markdown_to_adf(&md).unwrap();
5837        let items = rt_doc.content[0].content.as_ref().unwrap();
5838        assert_eq!(
5839            items[1].node_type, "taskList",
5840            "nested taskList should survive round-trip"
5841        );
5842        let nested_item = &items[1].content.as_ref().unwrap()[0];
5843        assert_eq!(nested_item.attrs.as_ref().unwrap()["state"], "DONE");
5844    }
5845
5846    /// Issue #506: multiple nested taskLists at the same level.
5847    #[test]
5848    fn adf_multiple_nested_tasklists() {
5849        let json = r#"{
5850            "version": 1,
5851            "type": "doc",
5852            "content": [{
5853                "type": "taskList",
5854                "attrs": {"localId": ""},
5855                "content": [
5856                    {
5857                        "type": "taskItem",
5858                        "attrs": {"localId": "", "state": "TODO"},
5859                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "first parent"}]}]
5860                    },
5861                    {
5862                        "type": "taskList",
5863                        "attrs": {"localId": ""},
5864                        "content": [{
5865                            "type": "taskItem",
5866                            "attrs": {"localId": "", "state": "TODO"},
5867                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child A"}]}]
5868                        }]
5869                    },
5870                    {
5871                        "type": "taskItem",
5872                        "attrs": {"localId": "", "state": "TODO"},
5873                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "second parent"}]}]
5874                    },
5875                    {
5876                        "type": "taskList",
5877                        "attrs": {"localId": ""},
5878                        "content": [{
5879                            "type": "taskItem",
5880                            "attrs": {"localId": "", "state": "DONE"},
5881                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child B"}]}]
5882                        }]
5883                    }
5884                ]
5885            }]
5886        }"#;
5887        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5888        let md = adf_to_markdown(&doc).unwrap();
5889        assert!(md.contains("- [ ] first parent"), "got: {md}");
5890        assert!(md.contains("  - [ ] child A"), "got: {md}");
5891        assert!(md.contains("- [ ] second parent"), "got: {md}");
5892        assert!(md.contains("  - [x] child B"), "got: {md}");
5893    }
5894
5895    /// Issue #506: second round-trip is stable (idempotent after first
5896    /// structural normalisation).
5897    #[test]
5898    fn round_trip_nested_tasklist_stable() {
5899        let json = r#"{
5900            "version": 1,
5901            "type": "doc",
5902            "content": [{
5903                "type": "taskList",
5904                "attrs": {"localId": ""},
5905                "content": [
5906                    {
5907                        "type": "taskItem",
5908                        "attrs": {"localId": "", "state": "TODO"},
5909                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent"}]}]
5910                    },
5911                    {
5912                        "type": "taskList",
5913                        "attrs": {"localId": ""},
5914                        "content": [{
5915                            "type": "taskItem",
5916                            "attrs": {"localId": "", "state": "TODO"},
5917                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child"}]}]
5918                        }]
5919                    }
5920                ]
5921            }]
5922        }"#;
5923        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5924        // First round-trip.
5925        let md1 = adf_to_markdown(&doc).unwrap();
5926        let rt1 = markdown_to_adf(&md1).unwrap();
5927        // Second round-trip.
5928        let md2 = adf_to_markdown(&rt1).unwrap();
5929        let rt2 = markdown_to_adf(&md2).unwrap();
5930        // Markdown output should be identical after first normalisation.
5931        assert_eq!(md1, md2, "markdown should be stable across round-trips");
5932        // ADF structure should also be stable.
5933        let rt1_json = serde_json::to_string(&rt1).unwrap();
5934        let rt2_json = serde_json::to_string(&rt2).unwrap();
5935        assert_eq!(
5936            rt1_json, rt2_json,
5937            "ADF should be stable across round-trips"
5938        );
5939    }
5940
5941    /// Issue #506: task item with text and mixed indented sub-content
5942    /// (taskList + non-taskList block).  Exercises the `child_nodes` branch
5943    /// where non-taskList blocks stay as children of the taskItem while
5944    /// taskLists are promoted to siblings.
5945    #[test]
5946    fn task_item_mixed_sub_content_splits_siblings() {
5947        let md = "- [ ] Parent task\n  - [ ] Sub task\n  Some paragraph\n";
5948        let doc = markdown_to_adf(md).unwrap();
5949        let items = doc.content[0].content.as_ref().unwrap();
5950        // taskItem + sibling taskList
5951        assert_eq!(items.len(), 2, "got: {items:?}");
5952        assert_eq!(items[0].node_type, "taskItem");
5953        let parent_content = items[0].content.as_ref().unwrap();
5954        // Inline text + paragraph block (the non-taskList sub-content)
5955        assert!(
5956            parent_content.iter().any(|n| n.node_type == "paragraph"),
5957            "non-taskList sub-content should stay as child: {parent_content:?}"
5958        );
5959        // Sibling taskList
5960        assert_eq!(items[1].node_type, "taskList");
5961    }
5962
5963    /// Issue #506: empty task item with mixed indented sub-content hits the
5964    /// `None` arm of the `task.content` match when promoting taskLists to
5965    /// siblings.
5966    #[test]
5967    fn empty_task_item_mixed_sub_content_none_arm() {
5968        let md = "- [ ] \n  Some paragraph\n  - [ ] Sub task\n";
5969        let doc = markdown_to_adf(md).unwrap();
5970        let items = doc.content[0].content.as_ref().unwrap();
5971        // taskItem (with paragraph child) + sibling taskList
5972        assert_eq!(items.len(), 2, "got: {items:?}");
5973        assert_eq!(items[0].node_type, "taskItem");
5974        let parent_content = items[0].content.as_ref().unwrap();
5975        assert!(
5976            parent_content.iter().any(|n| n.node_type == "paragraph"),
5977            "paragraph should be assigned to taskItem: {parent_content:?}"
5978        );
5979        assert_eq!(items[1].node_type, "taskList");
5980    }
5981
5982    /// Issue #506: task item with text and only non-taskList sub-content
5983    /// (no sibling taskLists).  Exercises the fall-through path where
5984    /// `sibling_task_lists` is empty and child_nodes are appended to
5985    /// the existing task content (Some arm).
5986    #[test]
5987    fn task_item_text_with_non_tasklist_sub_content_only() {
5988        let md = "- [ ] My task\n  Extra paragraph content\n";
5989        let doc = markdown_to_adf(md).unwrap();
5990        let items = doc.content[0].content.as_ref().unwrap();
5991        // Single taskItem — no sibling taskLists to extract.
5992        assert_eq!(items.len(), 1, "got: {items:?}");
5993        assert_eq!(items[0].node_type, "taskItem");
5994        let content = items[0].content.as_ref().unwrap();
5995        // Inline text + sub-paragraph
5996        assert!(
5997            content.iter().any(|n| n.node_type == "paragraph"),
5998            "paragraph sub-content should be a child of taskItem: {content:?}"
5999        );
6000    }
6001
6002    /// Covers the else branch in render_list_item_content where the first
6003    /// child of a list item is a block node (not paragraph, not inline).
6004    #[test]
6005    fn adf_list_item_leading_block_node() {
6006        let json = r#"{
6007            "version": 1,
6008            "type": "doc",
6009            "content": [{
6010                "type": "bulletList",
6011                "content": [{
6012                    "type": "listItem",
6013                    "content": [{
6014                        "type": "codeBlock",
6015                        "attrs": {"language": "rust"},
6016                        "content": [{"type": "text", "text": "let x = 1;"}]
6017                    }]
6018                }]
6019            }]
6020        }"#;
6021        let doc: AdfDocument = serde_json::from_str(json).unwrap();
6022        let md = adf_to_markdown(&doc).unwrap();
6023        assert!(md.contains("```rust"), "got: {md}");
6024        assert!(md.contains("let x = 1;"), "got: {md}");
6025        // Continuation lines must be indented so the block stays inside
6026        // the list item on round-trip (issue #511).
6027        for line in md.lines() {
6028            if line.starts_with("- ") {
6029                continue; // first line with list marker
6030            }
6031            if line.trim().is_empty() {
6032                continue;
6033            }
6034            assert!(
6035                line.starts_with("  "),
6036                "continuation line not indented: {line:?}"
6037            );
6038        }
6039    }
6040
6041    /// Round-trip a codeBlock inside a listItem whose content contains a
6042    /// backtick character — the exact reproducer from issue #511.
6043    #[test]
6044    fn code_block_in_list_item_backtick_roundtrip() {
6045        let json = r#"{
6046            "version": 1,
6047            "type": "doc",
6048            "content": [{
6049                "type": "bulletList",
6050                "content": [{
6051                    "type": "listItem",
6052                    "content": [{
6053                        "type": "codeBlock",
6054                        "attrs": {"language": ""},
6055                        "content": [{"type": "text", "text": "error: some value with a backtick ` at end"}]
6056                    }]
6057                }]
6058            }]
6059        }"#;
6060        let original: AdfDocument = serde_json::from_str(json).unwrap();
6061        let md = adf_to_markdown(&original).unwrap();
6062        let roundtripped = markdown_to_adf(&md).unwrap();
6063        let list = &roundtripped.content[0];
6064        assert_eq!(list.node_type, "bulletList", "top node: {}", list.node_type);
6065        let item = &list.content.as_ref().unwrap()[0];
6066        let first_child = &item.content.as_ref().unwrap()[0];
6067        assert_eq!(
6068            first_child.node_type, "codeBlock",
6069            "expected codeBlock, got: {}",
6070            first_child.node_type
6071        );
6072        let text = first_child.content.as_ref().unwrap()[0]
6073            .text
6074            .as_deref()
6075            .unwrap();
6076        assert_eq!(text, "error: some value with a backtick ` at end");
6077    }
6078
6079    /// Code block with language tag inside a list item round-trips.
6080    #[test]
6081    fn code_block_with_language_in_list_item_roundtrip() {
6082        let json = r#"{
6083            "version": 1,
6084            "type": "doc",
6085            "content": [{
6086                "type": "bulletList",
6087                "content": [{
6088                    "type": "listItem",
6089                    "content": [{
6090                        "type": "codeBlock",
6091                        "attrs": {"language": "rust"},
6092                        "content": [{"type": "text", "text": "fn main() {\n    println!(\"hello\");\n}"}]
6093                    }]
6094                }]
6095            }]
6096        }"#;
6097        let original: AdfDocument = serde_json::from_str(json).unwrap();
6098        let md = adf_to_markdown(&original).unwrap();
6099        let roundtripped = markdown_to_adf(&md).unwrap();
6100        let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
6101        let code = &item.content.as_ref().unwrap()[0];
6102        assert_eq!(code.node_type, "codeBlock");
6103        let lang = code
6104            .attrs
6105            .as_ref()
6106            .and_then(|a| a.get("language"))
6107            .and_then(serde_json::Value::as_str)
6108            .unwrap_or("");
6109        assert_eq!(lang, "rust");
6110        let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
6111        assert!(text.contains("println!"), "code content: {text}");
6112    }
6113
6114    /// Code block in an ordered list item round-trips correctly.
6115    #[test]
6116    fn code_block_in_ordered_list_item_roundtrip() {
6117        let json = r#"{
6118            "version": 1,
6119            "type": "doc",
6120            "content": [{
6121                "type": "orderedList",
6122                "attrs": {"order": 1},
6123                "content": [{
6124                    "type": "listItem",
6125                    "content": [{
6126                        "type": "codeBlock",
6127                        "attrs": {"language": ""},
6128                        "content": [{"type": "text", "text": "backtick ` here"}]
6129                    }]
6130                }]
6131            }]
6132        }"#;
6133        let original: AdfDocument = serde_json::from_str(json).unwrap();
6134        let md = adf_to_markdown(&original).unwrap();
6135        let roundtripped = markdown_to_adf(&md).unwrap();
6136        let list = &roundtripped.content[0];
6137        assert_eq!(list.node_type, "orderedList");
6138        let item = &list.content.as_ref().unwrap()[0];
6139        let code = &item.content.as_ref().unwrap()[0];
6140        assert_eq!(code.node_type, "codeBlock");
6141        let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
6142        assert_eq!(text, "backtick ` here");
6143    }
6144
6145    /// A list item with a code block followed by a paragraph round-trips.
6146    #[test]
6147    fn code_block_then_paragraph_in_list_item() {
6148        let json = r#"{
6149            "version": 1,
6150            "type": "doc",
6151            "content": [{
6152                "type": "bulletList",
6153                "content": [{
6154                    "type": "listItem",
6155                    "content": [
6156                        {
6157                            "type": "codeBlock",
6158                            "attrs": {"language": ""},
6159                            "content": [{"type": "text", "text": "code with ` backtick"}]
6160                        },
6161                        {
6162                            "type": "paragraph",
6163                            "content": [{"type": "text", "text": "description"}]
6164                        }
6165                    ]
6166                }]
6167            }]
6168        }"#;
6169        let original: AdfDocument = serde_json::from_str(json).unwrap();
6170        let md = adf_to_markdown(&original).unwrap();
6171        let roundtripped = markdown_to_adf(&md).unwrap();
6172        let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
6173        let children = item.content.as_ref().unwrap();
6174        assert_eq!(children[0].node_type, "codeBlock");
6175        assert_eq!(children[1].node_type, "paragraph");
6176    }
6177
6178    /// Multiple backticks in code block content round-trip.
6179    #[test]
6180    fn code_block_multiple_backticks_in_list_item() {
6181        let json = r#"{
6182            "version": 1,
6183            "type": "doc",
6184            "content": [{
6185                "type": "bulletList",
6186                "content": [{
6187                    "type": "listItem",
6188                    "content": [{
6189                        "type": "codeBlock",
6190                        "attrs": {"language": ""},
6191                        "content": [{"type": "text", "text": "a ` b `` c ``` d"}]
6192                    }]
6193                }]
6194            }]
6195        }"#;
6196        let original: AdfDocument = serde_json::from_str(json).unwrap();
6197        let md = adf_to_markdown(&original).unwrap();
6198        let roundtripped = markdown_to_adf(&md).unwrap();
6199        let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
6200        let code = &item.content.as_ref().unwrap()[0];
6201        assert_eq!(code.node_type, "codeBlock");
6202        let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
6203        assert_eq!(text, "a ` b `` c ``` d");
6204    }
6205
6206    /// Media as the first child of a list item with a subsequent paragraph
6207    /// exercises the media + sub_lines branch in `parse_list_item_first_line`.
6208    #[test]
6209    fn media_first_child_with_sub_content_in_list_item() {
6210        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
6211          {"type":"listItem","content":[
6212            {"type":"mediaSingle","attrs":{"layout":"center"},
6213             "content":[{"type":"media","attrs":{"type":"file","id":"img-99","collection":"col-x","height":50,"width":100}}]},
6214            {"type":"paragraph","content":[{"type":"text","text":"Caption below"}]}
6215          ]}
6216        ]}]}"#;
6217        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
6218        let md = adf_to_markdown(&doc).unwrap();
6219        let rt = markdown_to_adf(&md).unwrap();
6220        let item = &rt.content[0].content.as_ref().unwrap()[0];
6221        let children = item.content.as_ref().unwrap();
6222        assert_eq!(
6223            children.len(),
6224            2,
6225            "expected 2 children, got {}",
6226            children.len()
6227        );
6228        assert_eq!(children[0].node_type, "mediaSingle");
6229        let media = &children[0].content.as_ref().unwrap()[0];
6230        assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-99");
6231        assert_eq!(children[1].node_type, "paragraph");
6232    }
6233
6234    #[test]
6235    fn inline_bold() {
6236        let doc = markdown_to_adf("Some **bold** text").unwrap();
6237        let content = doc.content[0].content.as_ref().unwrap();
6238        assert!(content.len() >= 3);
6239        let bold_node = &content[1];
6240        assert_eq!(bold_node.text.as_deref(), Some("bold"));
6241        let marks = bold_node.marks.as_ref().unwrap();
6242        assert_eq!(marks[0].mark_type, "strong");
6243    }
6244
6245    #[test]
6246    fn inline_italic() {
6247        let doc = markdown_to_adf("Some *italic* text").unwrap();
6248        let content = doc.content[0].content.as_ref().unwrap();
6249        let italic_node = &content[1];
6250        assert_eq!(italic_node.text.as_deref(), Some("italic"));
6251        let marks = italic_node.marks.as_ref().unwrap();
6252        assert_eq!(marks[0].mark_type, "em");
6253    }
6254
6255    #[test]
6256    fn inline_code() {
6257        let doc = markdown_to_adf("Use `code` here").unwrap();
6258        let content = doc.content[0].content.as_ref().unwrap();
6259        let code_node = &content[1];
6260        assert_eq!(code_node.text.as_deref(), Some("code"));
6261        let marks = code_node.marks.as_ref().unwrap();
6262        assert_eq!(marks[0].mark_type, "code");
6263    }
6264
6265    /// Issue #578: a code-marked text with an internal backtick must be
6266    /// emitted using double-backtick delimiters so it round-trips as a
6267    /// single node rather than being split on the inner backtick.
6268    #[test]
6269    fn inline_code_with_backtick_emitted_with_double_delimiters() {
6270        let doc = AdfDocument {
6271            version: 1,
6272            doc_type: "doc".to_string(),
6273            content: vec![AdfNode::paragraph(vec![
6274                AdfNode::text("Run "),
6275                AdfNode::text_with_marks(
6276                    "ADD `custom_threshold` TEXT NOT NULL",
6277                    vec![AdfMark::code()],
6278                ),
6279                AdfNode::text(" to update the schema."),
6280            ])],
6281        };
6282        let md = adf_to_markdown(&doc).unwrap();
6283        assert!(
6284            md.contains("``ADD `custom_threshold` TEXT NOT NULL``"),
6285            "expected double-backtick delimiters, got: {md}"
6286        );
6287    }
6288
6289    /// Issue #578: double-backtick delimited code spans parse as a single
6290    /// code-marked text node that preserves the embedded single backticks.
6291    #[test]
6292    fn inline_code_double_backtick_delimiters_parse() {
6293        let doc = markdown_to_adf("Run ``ADD `custom_threshold` TEXT NOT NULL`` now").unwrap();
6294        let content = doc.content[0].content.as_ref().unwrap();
6295        assert_eq!(content.len(), 3, "content: {content:?}");
6296        let code_node = &content[1];
6297        assert_eq!(
6298            code_node.text.as_deref(),
6299            Some("ADD `custom_threshold` TEXT NOT NULL")
6300        );
6301        let marks = code_node.marks.as_ref().unwrap();
6302        assert_eq!(marks[0].mark_type, "code");
6303    }
6304
6305    /// Issue #578: the full reproducer — a code-marked text with inner
6306    /// backticks survives ADF → JFM → ADF round-trip intact.
6307    #[test]
6308    fn inline_code_with_backtick_roundtrip() {
6309        let json = r#"{
6310            "version": 1,
6311            "type": "doc",
6312            "content": [{
6313                "type": "paragraph",
6314                "content": [
6315                    {"type": "text", "text": "Run "},
6316                    {
6317                        "type": "text",
6318                        "text": "ADD `custom_threshold` TEXT NOT NULL",
6319                        "marks": [{"type": "code"}]
6320                    },
6321                    {"type": "text", "text": " to update the schema."}
6322                ]
6323            }]
6324        }"#;
6325        let original: AdfDocument = serde_json::from_str(json).unwrap();
6326        let md = adf_to_markdown(&original).unwrap();
6327        let roundtripped = markdown_to_adf(&md).unwrap();
6328        let para = &roundtripped.content[0];
6329        let children = para.content.as_ref().unwrap();
6330        assert_eq!(children.len(), 3, "expected 3 children, got: {children:?}");
6331        assert_eq!(children[0].text.as_deref(), Some("Run "));
6332        assert_eq!(
6333            children[1].text.as_deref(),
6334            Some("ADD `custom_threshold` TEXT NOT NULL")
6335        );
6336        let marks = children[1].marks.as_ref().unwrap();
6337        assert_eq!(marks.len(), 1);
6338        assert_eq!(marks[0].mark_type, "code");
6339        assert_eq!(children[2].text.as_deref(), Some(" to update the schema."));
6340    }
6341
6342    /// A code-marked text containing a run of two backticks should be
6343    /// emitted with triple-backtick delimiters and round-trip intact —
6344    /// the first line of the paragraph also starts with the fence so this
6345    /// exercises the info-string-with-backtick fence-opener rejection.
6346    #[test]
6347    fn inline_code_with_double_backtick_roundtrip() {
6348        let doc = AdfDocument {
6349            version: 1,
6350            doc_type: "doc".to_string(),
6351            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6352                "x `` y",
6353                vec![AdfMark::code()],
6354            )])],
6355        };
6356        let md = adf_to_markdown(&doc).unwrap();
6357        let roundtripped = markdown_to_adf(&md).unwrap();
6358        let content = roundtripped.content[0].content.as_ref().unwrap();
6359        assert_eq!(content.len(), 1);
6360        assert_eq!(content[0].text.as_deref(), Some("x `` y"));
6361        let marks = content[0].marks.as_ref().unwrap();
6362        assert_eq!(marks[0].mark_type, "code");
6363    }
6364
6365    /// A code-marked text that begins with a backtick must be padded on
6366    /// both sides so the CommonMark space-stripping rule reconstructs it.
6367    #[test]
6368    fn inline_code_leading_backtick_roundtrip() {
6369        let doc = AdfDocument {
6370            version: 1,
6371            doc_type: "doc".to_string(),
6372            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6373                "`start",
6374                vec![AdfMark::code()],
6375            )])],
6376        };
6377        let md = adf_to_markdown(&doc).unwrap();
6378        let roundtripped = markdown_to_adf(&md).unwrap();
6379        let content = roundtripped.content[0].content.as_ref().unwrap();
6380        assert_eq!(content[0].text.as_deref(), Some("`start"));
6381        assert_eq!(content[0].marks.as_ref().unwrap()[0].mark_type, "code");
6382    }
6383
6384    /// A code-marked text that ends with a backtick must also survive.
6385    #[test]
6386    fn inline_code_trailing_backtick_roundtrip() {
6387        let doc = AdfDocument {
6388            version: 1,
6389            doc_type: "doc".to_string(),
6390            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6391                "end`",
6392                vec![AdfMark::code()],
6393            )])],
6394        };
6395        let md = adf_to_markdown(&doc).unwrap();
6396        let roundtripped = markdown_to_adf(&md).unwrap();
6397        let content = roundtripped.content[0].content.as_ref().unwrap();
6398        assert_eq!(content[0].text.as_deref(), Some("end`"));
6399    }
6400
6401    /// Content that both begins and ends with a space (but is not all
6402    /// spaces) needs padding so the stripping rule leaves it intact.
6403    #[test]
6404    fn inline_code_space_padded_content_roundtrip() {
6405        let doc = AdfDocument {
6406            version: 1,
6407            doc_type: "doc".to_string(),
6408            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6409                " foo ",
6410                vec![AdfMark::code()],
6411            )])],
6412        };
6413        let md = adf_to_markdown(&doc).unwrap();
6414        let roundtripped = markdown_to_adf(&md).unwrap();
6415        let content = roundtripped.content[0].content.as_ref().unwrap();
6416        assert_eq!(content[0].text.as_deref(), Some(" foo "));
6417    }
6418
6419    /// All-space content must round-trip without the stripping rule
6420    /// kicking in (per CommonMark: all-space content is not stripped).
6421    #[test]
6422    fn inline_code_all_spaces_roundtrip() {
6423        let doc = AdfDocument {
6424            version: 1,
6425            doc_type: "doc".to_string(),
6426            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6427                "   ",
6428                vec![AdfMark::code()],
6429            )])],
6430        };
6431        let md = adf_to_markdown(&doc).unwrap();
6432        let roundtripped = markdown_to_adf(&md).unwrap();
6433        let content = roundtripped.content[0].content.as_ref().unwrap();
6434        assert_eq!(content[0].text.as_deref(), Some("   "));
6435    }
6436
6437    /// A code+link mark where the code text contains a backtick must also
6438    /// round-trip — verifies the link branch of code-span rendering.
6439    #[test]
6440    fn inline_code_with_link_and_backtick_roundtrip() {
6441        let doc = AdfDocument {
6442            version: 1,
6443            doc_type: "doc".to_string(),
6444            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6445                "fn `inner`",
6446                vec![AdfMark::code(), AdfMark::link("https://example.com")],
6447            )])],
6448        };
6449        let md = adf_to_markdown(&doc).unwrap();
6450        assert!(
6451            md.contains("`` fn `inner` ``"),
6452            "expected padded double-backtick delimiters inside link, got: {md}"
6453        );
6454        let roundtripped = markdown_to_adf(&md).unwrap();
6455        let content = roundtripped.content[0].content.as_ref().unwrap();
6456        assert_eq!(content[0].text.as_deref(), Some("fn `inner`"));
6457        let mark_types: Vec<&str> = content[0]
6458            .marks
6459            .as_ref()
6460            .unwrap()
6461            .iter()
6462            .map(|m| m.mark_type.as_str())
6463            .collect();
6464        assert!(mark_types.contains(&"code"));
6465        assert!(mark_types.contains(&"link"));
6466    }
6467
6468    /// Unmatched opening backticks must not be parsed as a code span.
6469    #[test]
6470    fn inline_code_unmatched_run_is_plain_text() {
6471        let doc = markdown_to_adf("foo ``bar baz").unwrap();
6472        let content = doc.content[0].content.as_ref().unwrap();
6473        assert_eq!(content.len(), 1);
6474        assert_eq!(content[0].text.as_deref(), Some("foo ``bar baz"));
6475        assert!(content[0].marks.is_none());
6476    }
6477
6478    /// Mismatched delimiter lengths must not form a code span.  Per
6479    /// CommonMark the opening 2-backtick run and the trailing 1-backtick
6480    /// run never form a valid code span and the characters stay literal.
6481    #[test]
6482    fn inline_code_mismatched_delimiters_is_plain_text() {
6483        let doc = markdown_to_adf("``foo` bar").unwrap();
6484        let content = doc.content[0].content.as_ref().unwrap();
6485        assert_eq!(content.len(), 1);
6486        assert_eq!(content[0].text.as_deref(), Some("``foo` bar"));
6487        assert!(content[0].marks.is_none());
6488    }
6489
6490    #[test]
6491    fn inline_code_delimiter_chooses_correct_length() {
6492        assert_eq!(inline_code_delimiter("no ticks"), (1, false));
6493        assert_eq!(inline_code_delimiter("one ` here"), (2, false));
6494        assert_eq!(inline_code_delimiter("two `` here"), (3, false));
6495        assert_eq!(inline_code_delimiter("three ``` here"), (4, false));
6496        assert_eq!(inline_code_delimiter("`leading"), (2, true));
6497        assert_eq!(inline_code_delimiter("trailing`"), (2, true));
6498        assert_eq!(inline_code_delimiter(" foo "), (1, true));
6499        assert_eq!(inline_code_delimiter(" "), (1, false));
6500        assert_eq!(inline_code_delimiter("   "), (1, false));
6501        assert_eq!(inline_code_delimiter(" foo"), (1, false));
6502    }
6503
6504    #[test]
6505    fn try_parse_inline_code_strips_paired_spaces() {
6506        let (end, content) = try_parse_inline_code("`` `foo` ``", 0).unwrap();
6507        assert_eq!(end, 11);
6508        assert_eq!(content, "`foo`");
6509    }
6510
6511    #[test]
6512    fn try_parse_inline_code_all_space_content_is_preserved() {
6513        let (_end, content) = try_parse_inline_code("`   `", 0).unwrap();
6514        assert_eq!(content, "   ");
6515    }
6516
6517    #[test]
6518    fn try_parse_inline_code_single_run_matches_first_close() {
6519        let (end, content) = try_parse_inline_code("`foo` tail", 0).unwrap();
6520        assert_eq!(end, 5);
6521        assert_eq!(content, "foo");
6522    }
6523
6524    #[test]
6525    fn try_parse_inline_code_no_match_returns_none() {
6526        assert!(try_parse_inline_code("``unmatched", 0).is_none());
6527        assert!(try_parse_inline_code("plain text", 0).is_none());
6528    }
6529
6530    #[test]
6531    fn is_code_fence_opener_rejects_info_with_backtick() {
6532        assert!(is_code_fence_opener("```"));
6533        assert!(is_code_fence_opener("```rust"));
6534        assert!(is_code_fence_opener("```\"\""));
6535        assert!(!is_code_fence_opener("```x `` y```"));
6536        assert!(!is_code_fence_opener("``not-enough"));
6537        assert!(!is_code_fence_opener("no fence"));
6538    }
6539
6540    #[test]
6541    fn inline_strikethrough() {
6542        let doc = markdown_to_adf("Some ~~deleted~~ text").unwrap();
6543        let content = doc.content[0].content.as_ref().unwrap();
6544        let strike_node = &content[1];
6545        assert_eq!(strike_node.text.as_deref(), Some("deleted"));
6546        let marks = strike_node.marks.as_ref().unwrap();
6547        assert_eq!(marks[0].mark_type, "strike");
6548    }
6549
6550    #[test]
6551    fn inline_link() {
6552        let doc = markdown_to_adf("Click [here](https://example.com) now").unwrap();
6553        let content = doc.content[0].content.as_ref().unwrap();
6554        let link_node = &content[1];
6555        assert_eq!(link_node.text.as_deref(), Some("here"));
6556        let marks = link_node.marks.as_ref().unwrap();
6557        assert_eq!(marks[0].mark_type, "link");
6558    }
6559
6560    #[test]
6561    fn block_image() {
6562        let md = "![Alt text](https://example.com/image.png)";
6563        let doc = markdown_to_adf(md).unwrap();
6564        assert_eq!(doc.content[0].node_type, "mediaSingle");
6565    }
6566
6567    #[test]
6568    fn table() {
6569        let md = "| A | B |\n| --- | --- |\n| 1 | 2 |";
6570        let doc = markdown_to_adf(md).unwrap();
6571        assert_eq!(doc.content[0].node_type, "table");
6572        let rows = doc.content[0].content.as_ref().unwrap();
6573        assert_eq!(rows.len(), 2); // header + 1 body row
6574    }
6575
6576    // ── adf_to_markdown tests ───────────────────────────────────────
6577
6578    #[test]
6579    fn adf_paragraph_to_markdown() {
6580        let doc = AdfDocument {
6581            version: 1,
6582            doc_type: "doc".to_string(),
6583            content: vec![AdfNode::paragraph(vec![AdfNode::text("Hello world")])],
6584        };
6585        let md = adf_to_markdown(&doc).unwrap();
6586        assert_eq!(md.trim(), "Hello world");
6587    }
6588
6589    #[test]
6590    fn adf_heading_to_markdown() {
6591        let doc = AdfDocument {
6592            version: 1,
6593            doc_type: "doc".to_string(),
6594            content: vec![AdfNode::heading(2, vec![AdfNode::text("Title")])],
6595        };
6596        let md = adf_to_markdown(&doc).unwrap();
6597        assert_eq!(md.trim(), "## Title");
6598    }
6599
6600    #[test]
6601    fn adf_bold_to_markdown() {
6602        let doc = AdfDocument {
6603            version: 1,
6604            doc_type: "doc".to_string(),
6605            content: vec![AdfNode::paragraph(vec![
6606                AdfNode::text("Normal "),
6607                AdfNode::text_with_marks("bold", vec![AdfMark::strong()]),
6608                AdfNode::text(" text"),
6609            ])],
6610        };
6611        let md = adf_to_markdown(&doc).unwrap();
6612        assert_eq!(md.trim(), "Normal **bold** text");
6613    }
6614
6615    #[test]
6616    fn adf_code_block_to_markdown() {
6617        let doc = AdfDocument {
6618            version: 1,
6619            doc_type: "doc".to_string(),
6620            content: vec![AdfNode::code_block(Some("rust"), "let x = 1;")],
6621        };
6622        let md = adf_to_markdown(&doc).unwrap();
6623        assert!(md.contains("```rust"));
6624        assert!(md.contains("let x = 1;"));
6625        assert!(md.contains("```"));
6626    }
6627
6628    #[test]
6629    fn adf_rule_to_markdown() {
6630        let doc = AdfDocument {
6631            version: 1,
6632            doc_type: "doc".to_string(),
6633            content: vec![AdfNode::rule()],
6634        };
6635        let md = adf_to_markdown(&doc).unwrap();
6636        assert!(md.contains("---"));
6637    }
6638
6639    #[test]
6640    fn adf_bullet_list_to_markdown() {
6641        let doc = AdfDocument {
6642            version: 1,
6643            doc_type: "doc".to_string(),
6644            content: vec![AdfNode::bullet_list(vec![
6645                AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("A")])]),
6646                AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("B")])]),
6647            ])],
6648        };
6649        let md = adf_to_markdown(&doc).unwrap();
6650        assert!(md.contains("- A"));
6651        assert!(md.contains("- B"));
6652    }
6653
6654    #[test]
6655    fn adf_link_to_markdown() {
6656        let doc = AdfDocument {
6657            version: 1,
6658            doc_type: "doc".to_string(),
6659            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6660                "click",
6661                vec![AdfMark::link("https://example.com")],
6662            )])],
6663        };
6664        let md = adf_to_markdown(&doc).unwrap();
6665        assert_eq!(md.trim(), "[click](https://example.com)");
6666    }
6667
6668    #[test]
6669    fn unsupported_block_preserved_as_json() {
6670        let doc = AdfDocument {
6671            version: 1,
6672            doc_type: "doc".to_string(),
6673            content: vec![AdfNode {
6674                node_type: "unknownBlock".to_string(),
6675                attrs: Some(serde_json::json!({"key": "value"})),
6676                content: None,
6677                text: None,
6678                marks: None,
6679                local_id: None,
6680                parameters: None,
6681            }],
6682        };
6683        let md = adf_to_markdown(&doc).unwrap();
6684        assert!(md.contains("```adf-unsupported"));
6685        assert!(md.contains("\"unknownBlock\""));
6686    }
6687
6688    #[test]
6689    fn unsupported_block_round_trips() {
6690        let original = AdfDocument {
6691            version: 1,
6692            doc_type: "doc".to_string(),
6693            content: vec![AdfNode {
6694                node_type: "unknownBlock".to_string(),
6695                attrs: Some(serde_json::json!({"key": "value"})),
6696                content: None,
6697                text: None,
6698                marks: None,
6699                local_id: None,
6700                parameters: None,
6701            }],
6702        };
6703        let md = adf_to_markdown(&original).unwrap();
6704        let restored = markdown_to_adf(&md).unwrap();
6705        assert_eq!(restored.content[0].node_type, "unknownBlock");
6706        assert_eq!(restored.content[0].attrs.as_ref().unwrap()["key"], "value");
6707    }
6708
6709    // ── Round-trip tests ────────────────────────────────────────────
6710
6711    #[test]
6712    fn round_trip_simple_document() {
6713        let md = "# Hello\n\nSome text with **bold** and *italic*.\n\n- Item 1\n- Item 2\n";
6714        let adf = markdown_to_adf(md).unwrap();
6715        let restored = adf_to_markdown(&adf).unwrap();
6716
6717        assert!(restored.contains("# Hello"));
6718        assert!(restored.contains("**bold**"));
6719        assert!(restored.contains("*italic*"));
6720        assert!(restored.contains("- Item 1"));
6721        assert!(restored.contains("- Item 2"));
6722    }
6723
6724    #[test]
6725    fn round_trip_code_block() {
6726        let md = "```python\nprint('hello')\n```\n";
6727        let adf = markdown_to_adf(md).unwrap();
6728        let restored = adf_to_markdown(&adf).unwrap();
6729
6730        assert!(restored.contains("```python"));
6731        assert!(restored.contains("print('hello')"));
6732    }
6733
6734    #[test]
6735    fn round_trip_code_block_no_attrs() {
6736        let adf_json = r#"{"version":1,"type":"doc","content":[
6737            {"type":"codeBlock","content":[{"type":"text","text":"plain code"}]}
6738        ]}"#;
6739        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
6740        assert!(doc.content[0].attrs.is_none());
6741        let md = adf_to_markdown(&doc).unwrap();
6742        let round_tripped = markdown_to_adf(&md).unwrap();
6743        assert!(round_tripped.content[0].attrs.is_none());
6744    }
6745
6746    #[test]
6747    fn round_trip_code_block_empty_language() {
6748        let adf_json = r#"{"version":1,"type":"doc","content":[
6749            {"type":"codeBlock","attrs":{"language":""},"content":[{"type":"text","text":"simple code block no backtick"}]}
6750        ]}"#;
6751        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
6752        let attrs = doc.content[0].attrs.as_ref().unwrap();
6753        assert_eq!(attrs["language"], "");
6754        let md = adf_to_markdown(&doc).unwrap();
6755        let round_tripped = markdown_to_adf(&md).unwrap();
6756        let rt_attrs = round_tripped.content[0].attrs.as_ref().unwrap();
6757        assert_eq!(rt_attrs["language"], "");
6758    }
6759
6760    #[test]
6761    fn round_trip_code_block_with_language() {
6762        let adf_json = r#"{"version":1,"type":"doc","content":[
6763            {"type":"codeBlock","attrs":{"language":"python"},"content":[{"type":"text","text":"print('hi')"}]}
6764        ]}"#;
6765        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
6766        let md = adf_to_markdown(&doc).unwrap();
6767        let round_tripped = markdown_to_adf(&md).unwrap();
6768        let rt_attrs = round_tripped.content[0].attrs.as_ref().unwrap();
6769        assert_eq!(rt_attrs["language"], "python");
6770    }
6771
6772    #[test]
6773    fn multiple_paragraphs() {
6774        let md = "First paragraph.\n\nSecond paragraph.\n";
6775        let adf = markdown_to_adf(md).unwrap();
6776        assert_eq!(adf.content.len(), 2);
6777        assert_eq!(adf.content[0].node_type, "paragraph");
6778        assert_eq!(adf.content[1].node_type, "paragraph");
6779    }
6780
6781    // ── Additional markdown_to_adf tests ───────────────────────────────
6782
6783    #[test]
6784    fn horizontal_rule_underscores() {
6785        let doc = markdown_to_adf("___").unwrap();
6786        assert_eq!(doc.content[0].node_type, "rule");
6787    }
6788
6789    #[test]
6790    fn not_a_horizontal_rule_too_short() {
6791        let doc = markdown_to_adf("--").unwrap();
6792        assert_eq!(doc.content[0].node_type, "paragraph");
6793    }
6794
6795    #[test]
6796    fn bullet_list_star_marker() {
6797        let md = "* Apple\n* Banana";
6798        let doc = markdown_to_adf(md).unwrap();
6799        assert_eq!(doc.content[0].node_type, "bulletList");
6800        let items = doc.content[0].content.as_ref().unwrap();
6801        assert_eq!(items.len(), 2);
6802    }
6803
6804    #[test]
6805    fn bullet_list_plus_marker() {
6806        let md = "+ One\n+ Two";
6807        let doc = markdown_to_adf(md).unwrap();
6808        assert_eq!(doc.content[0].node_type, "bulletList");
6809    }
6810
6811    #[test]
6812    fn ordered_list_non_one_start() {
6813        let md = "5. Fifth\n6. Sixth";
6814        let doc = markdown_to_adf(md).unwrap();
6815        let node = &doc.content[0];
6816        assert_eq!(node.node_type, "orderedList");
6817        let attrs = node.attrs.as_ref().unwrap();
6818        assert_eq!(attrs["order"], 5);
6819    }
6820
6821    #[test]
6822    fn ordered_list_start_at_one_omits_order_attr() {
6823        // Issue #547: order=1 is the default and must be omitted from attrs
6824        // so that ADF→JFM→ADF round-trip is byte-identical for the common
6825        // case where the source ADF has no attrs object on orderedList.
6826        let md = "1. First\n2. Second";
6827        let doc = markdown_to_adf(md).unwrap();
6828        let node = &doc.content[0];
6829        assert_eq!(node.node_type, "orderedList");
6830        assert!(
6831            node.attrs.is_none(),
6832            "attrs should be omitted when order=1, got: {:?}",
6833            node.attrs
6834        );
6835    }
6836
6837    #[test]
6838    fn blockquote_bare_marker() {
6839        // ">" with no space after
6840        let md = ">quoted text";
6841        let doc = markdown_to_adf(md).unwrap();
6842        assert_eq!(doc.content[0].node_type, "blockquote");
6843    }
6844
6845    #[test]
6846    fn image_no_alt() {
6847        let md = "![](https://example.com/img.png)";
6848        let doc = markdown_to_adf(md).unwrap();
6849        let node = &doc.content[0];
6850        assert_eq!(node.node_type, "mediaSingle");
6851        // media child should have no alt attr
6852        let media = &node.content.as_ref().unwrap()[0];
6853        let attrs = media.attrs.as_ref().unwrap();
6854        assert!(attrs.get("alt").is_none());
6855    }
6856
6857    #[test]
6858    fn image_with_alt() {
6859        let md = "![A photo](https://example.com/photo.jpg)";
6860        let doc = markdown_to_adf(md).unwrap();
6861        let media = &doc.content[0].content.as_ref().unwrap()[0];
6862        let attrs = media.attrs.as_ref().unwrap();
6863        assert_eq!(attrs["alt"], "A photo");
6864    }
6865
6866    #[test]
6867    fn table_multi_body_rows() {
6868        let md = "| H1 | H2 |\n| --- | --- |\n| a | b |\n| c | d |";
6869        let doc = markdown_to_adf(md).unwrap();
6870        let rows = doc.content[0].content.as_ref().unwrap();
6871        assert_eq!(rows.len(), 3); // header + 2 body rows
6872                                   // First row cells are tableHeader
6873        let header_cells = rows[0].content.as_ref().unwrap();
6874        assert_eq!(header_cells[0].node_type, "tableHeader");
6875        // Body row cells are tableCell
6876        let body_cells = rows[1].content.as_ref().unwrap();
6877        assert_eq!(body_cells[0].node_type, "tableCell");
6878    }
6879
6880    #[test]
6881    fn table_no_separator_is_not_table() {
6882        // Pipe characters without a separator row should not parse as table
6883        let md = "| not | a table |";
6884        let doc = markdown_to_adf(md).unwrap();
6885        assert_eq!(doc.content[0].node_type, "paragraph");
6886    }
6887
6888    #[test]
6889    fn inline_underscore_bold() {
6890        let doc = markdown_to_adf("Some __bold__ text").unwrap();
6891        let content = doc.content[0].content.as_ref().unwrap();
6892        let bold_node = &content[1];
6893        assert_eq!(bold_node.text.as_deref(), Some("bold"));
6894        let marks = bold_node.marks.as_ref().unwrap();
6895        assert_eq!(marks[0].mark_type, "strong");
6896    }
6897
6898    #[test]
6899    fn inline_underscore_italic() {
6900        let doc = markdown_to_adf("Some _italic_ text").unwrap();
6901        let content = doc.content[0].content.as_ref().unwrap();
6902        let italic_node = &content[1];
6903        assert_eq!(italic_node.text.as_deref(), Some("italic"));
6904        let marks = italic_node.marks.as_ref().unwrap();
6905        assert_eq!(marks[0].mark_type, "em");
6906    }
6907
6908    #[test]
6909    fn intraword_underscore_not_emphasis() {
6910        // Single intraword underscore pair: do_something_useful
6911        let doc = markdown_to_adf("call do_something_useful now").unwrap();
6912        let content = doc.content[0].content.as_ref().unwrap();
6913        assert_eq!(content.len(), 1, "should be a single text node");
6914        assert_eq!(
6915            content[0].text.as_deref(),
6916            Some("call do_something_useful now")
6917        );
6918        assert!(content[0].marks.is_none());
6919    }
6920
6921    #[test]
6922    fn intraword_underscore_multiple() {
6923        // Multiple intraword underscores: a_b_c_d
6924        let doc = markdown_to_adf("use a_b_c_d here").unwrap();
6925        let content = doc.content[0].content.as_ref().unwrap();
6926        assert_eq!(content.len(), 1);
6927        assert_eq!(content[0].text.as_deref(), Some("use a_b_c_d here"));
6928        assert!(content[0].marks.is_none());
6929    }
6930
6931    #[test]
6932    fn intraword_double_underscore_not_bold() {
6933        // Intraword double underscores: foo__bar__baz
6934        let doc = markdown_to_adf("foo__bar__baz").unwrap();
6935        let content = doc.content[0].content.as_ref().unwrap();
6936        assert_eq!(content.len(), 1);
6937        assert_eq!(content[0].text.as_deref(), Some("foo__bar__baz"));
6938        assert!(content[0].marks.is_none());
6939    }
6940
6941    #[test]
6942    fn intraword_triple_underscore_not_bold_italic() {
6943        // Intraword triple underscores: x___y___z
6944        let doc = markdown_to_adf("x___y___z").unwrap();
6945        let content = doc.content[0].content.as_ref().unwrap();
6946        assert_eq!(content.len(), 1);
6947        assert_eq!(content[0].text.as_deref(), Some("x___y___z"));
6948        assert!(content[0].marks.is_none());
6949    }
6950
6951    #[test]
6952    fn underscore_emphasis_still_works_with_spaces() {
6953        // Normal emphasis with spaces around delimiters should still work
6954        let doc = markdown_to_adf("some _italic_ here").unwrap();
6955        let content = doc.content[0].content.as_ref().unwrap();
6956        assert_eq!(content.len(), 3);
6957        assert_eq!(content[1].text.as_deref(), Some("italic"));
6958        let marks = content[1].marks.as_ref().unwrap();
6959        assert_eq!(marks[0].mark_type, "em");
6960    }
6961
6962    #[test]
6963    fn underscore_bold_still_works_with_spaces() {
6964        // Normal bold with spaces around delimiters should still work
6965        let doc = markdown_to_adf("some __bold__ here").unwrap();
6966        let content = doc.content[0].content.as_ref().unwrap();
6967        assert_eq!(content.len(), 3);
6968        assert_eq!(content[1].text.as_deref(), Some("bold"));
6969        let marks = content[1].marks.as_ref().unwrap();
6970        assert_eq!(marks[0].mark_type, "strong");
6971    }
6972
6973    #[test]
6974    fn intraword_underscore_closing_only() {
6975        // Opening _ is valid (preceded by space) but closing _ is intraword: _foo_bar
6976        let doc = markdown_to_adf("_foo_bar").unwrap();
6977        let content = doc.content[0].content.as_ref().unwrap();
6978        assert_eq!(content.len(), 1);
6979        assert_eq!(content[0].text.as_deref(), Some("_foo_bar"));
6980    }
6981
6982    #[test]
6983    fn intraword_double_underscore_closing_only() {
6984        // Opening __ is valid (at start) but closing __ is intraword: __foo__bar
6985        let doc = markdown_to_adf("__foo__bar").unwrap();
6986        let content = doc.content[0].content.as_ref().unwrap();
6987        assert_eq!(content.len(), 1);
6988        assert_eq!(content[0].text.as_deref(), Some("__foo__bar"));
6989    }
6990
6991    #[test]
6992    fn intraword_triple_underscore_closing_only() {
6993        // Opening ___ is valid (at start) but closing ___ is intraword: ___foo___bar
6994        let doc = markdown_to_adf("___foo___bar").unwrap();
6995        let content = doc.content[0].content.as_ref().unwrap();
6996        assert_eq!(content.len(), 1);
6997        assert_eq!(content[0].text.as_deref(), Some("___foo___bar"));
6998    }
6999
7000    #[test]
7001    fn asterisk_emphasis_unaffected_by_intraword_fix() {
7002        // Asterisks should still work for intraword emphasis (CommonMark allows this)
7003        let doc = markdown_to_adf("foo*bar*baz").unwrap();
7004        let content = doc.content[0].content.as_ref().unwrap();
7005        // Asterisks CAN be intraword emphasis per CommonMark
7006        assert!(content.len() > 1 || content[0].marks.is_some());
7007    }
7008
7009    #[test]
7010    fn intraword_underscore_at_start_of_text() {
7011        // Underscore at start of text is not intraword (no preceding alphanumeric)
7012        let doc = markdown_to_adf("_italic_ word").unwrap();
7013        let content = doc.content[0].content.as_ref().unwrap();
7014        assert_eq!(content[0].text.as_deref(), Some("italic"));
7015        let marks = content[0].marks.as_ref().unwrap();
7016        assert_eq!(marks[0].mark_type, "em");
7017    }
7018
7019    #[test]
7020    fn intraword_underscore_at_end_of_text() {
7021        // Underscore at end of text is not intraword (no following alphanumeric)
7022        let doc = markdown_to_adf("word _italic_").unwrap();
7023        let content = doc.content[0].content.as_ref().unwrap();
7024        let last = content.last().unwrap();
7025        assert_eq!(last.text.as_deref(), Some("italic"));
7026        let marks = last.marks.as_ref().unwrap();
7027        assert_eq!(marks[0].mark_type, "em");
7028    }
7029
7030    #[test]
7031    fn intraword_underscore_opening_only() {
7032        // Opening underscore is intraword but closing is not: a_b c_d
7033        // The first _ is intraword (a_b), so it shouldn't open emphasis
7034        let doc = markdown_to_adf("a_b c_d").unwrap();
7035        let content = doc.content[0].content.as_ref().unwrap();
7036        assert_eq!(content.len(), 1);
7037        assert_eq!(content[0].text.as_deref(), Some("a_b c_d"));
7038    }
7039
7040    #[test]
7041    fn intraword_underscore_roundtrip() {
7042        // The original reproducer from issue #438
7043        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"call the do_something_useful function"}]}]}"#;
7044        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7045        let jfm = adf_to_markdown(&adf).unwrap();
7046        let roundtripped = markdown_to_adf(&jfm).unwrap();
7047        let content = roundtripped.content[0].content.as_ref().unwrap();
7048        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7049        assert_eq!(
7050            content[0].text.as_deref(),
7051            Some("call the do_something_useful function")
7052        );
7053        assert!(content[0].marks.is_none());
7054    }
7055
7056    #[test]
7057    fn asterisk_emphasis_roundtrip() {
7058        // The original reproducer from issue #452
7059        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Status: *confirmed* and active"}]}]}"#;
7060        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7061        let jfm = adf_to_markdown(&adf).unwrap();
7062        let roundtripped = markdown_to_adf(&jfm).unwrap();
7063        let content = roundtripped.content[0].content.as_ref().unwrap();
7064        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7065        assert_eq!(
7066            content[0].text.as_deref(),
7067            Some("Status: *confirmed* and active")
7068        );
7069        assert!(content[0].marks.is_none());
7070    }
7071
7072    #[test]
7073    fn double_asterisk_roundtrip() {
7074        // **bold** delimiters in plain text should not become strong marks
7075        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use **kwargs in Python"}]}]}"#;
7076        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7077        let jfm = adf_to_markdown(&adf).unwrap();
7078        let roundtripped = markdown_to_adf(&jfm).unwrap();
7079        let content = roundtripped.content[0].content.as_ref().unwrap();
7080        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7081        assert_eq!(content[0].text.as_deref(), Some("Use **kwargs in Python"));
7082        assert!(content[0].marks.is_none());
7083    }
7084
7085    #[test]
7086    fn asterisk_with_em_mark_roundtrip() {
7087        // Text that already has an em mark should preserve both the mark and escaped content
7088        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a*b","marks":[{"type":"em"}]}]}]}"#;
7089        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7090        let jfm = adf_to_markdown(&adf).unwrap();
7091        let roundtripped = markdown_to_adf(&jfm).unwrap();
7092        let content = roundtripped.content[0].content.as_ref().unwrap();
7093        // Find the node with em mark
7094        let em_node = content.iter().find(|n| {
7095            n.marks
7096                .as_ref()
7097                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "em"))
7098        });
7099        assert!(em_node.is_some(), "should have an em-marked node");
7100        assert_eq!(em_node.unwrap().text.as_deref(), Some("a*b"));
7101    }
7102
7103    #[test]
7104    fn lone_asterisk_roundtrip() {
7105        // A single asterisk that cannot form emphasis should round-trip
7106        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"rating: 5 * stars"}]}]}"#;
7107        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7108        let jfm = adf_to_markdown(&adf).unwrap();
7109        let roundtripped = markdown_to_adf(&jfm).unwrap();
7110        let content = roundtripped.content[0].content.as_ref().unwrap();
7111        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7112        assert_eq!(content[0].text.as_deref(), Some("rating: 5 * stars"));
7113    }
7114
7115    #[test]
7116    fn escape_emphasis_markers_unit() {
7117        assert_eq!(escape_emphasis_markers("hello"), "hello");
7118        assert_eq!(escape_emphasis_markers("*bold*"), r"\*bold\*");
7119        assert_eq!(escape_emphasis_markers("**strong**"), r"\*\*strong\*\*");
7120        assert_eq!(escape_emphasis_markers("no stars"), "no stars");
7121        assert_eq!(escape_emphasis_markers("a * b"), r"a \* b");
7122        assert_eq!(escape_emphasis_markers(""), "");
7123    }
7124
7125    #[test]
7126    fn escape_emphasis_markers_underscore_intraword() {
7127        // Intraword underscores (alnum on both sides within the node) are
7128        // left as-is — the JFM parser will reject them as emphasis.
7129        assert_eq!(escape_emphasis_markers("foo_bar"), "foo_bar");
7130        assert_eq!(escape_emphasis_markers("a_b_c"), "a_b_c");
7131        assert_eq!(escape_emphasis_markers("foo__bar"), "foo__bar");
7132        assert_eq!(
7133            escape_emphasis_markers("call do_something_useful"),
7134            "call do_something_useful"
7135        );
7136    }
7137
7138    #[test]
7139    fn escape_emphasis_markers_underscore_at_boundary() {
7140        // Leading and trailing underscores get escaped — adjacent text nodes
7141        // could supply the alphanumeric needed to close emphasis (issue #554).
7142        assert_eq!(escape_emphasis_markers("_Action"), r"\_Action");
7143        assert_eq!(escape_emphasis_markers("Action_"), r"Action\_");
7144        assert_eq!(escape_emphasis_markers("_ "), r"\_ ");
7145        assert_eq!(escape_emphasis_markers(" _"), r" \_");
7146        assert_eq!(escape_emphasis_markers("_"), r"\_");
7147    }
7148
7149    #[test]
7150    fn escape_emphasis_markers_underscore_with_punctuation() {
7151        // Underscores adjacent to punctuation (not alphanumeric) get escaped.
7152        assert_eq!(escape_emphasis_markers("foo _bar"), r"foo \_bar");
7153        assert_eq!(escape_emphasis_markers("foo_ bar"), r"foo\_ bar");
7154        assert_eq!(escape_emphasis_markers("(_x_)"), r"(\_x\_)");
7155    }
7156
7157    #[test]
7158    fn find_unescaped_skips_backslash_escaped() {
7159        // Escaped `**` should not be found
7160        assert_eq!(find_unescaped(r"a\*\*b**", "**"), Some(6));
7161        // No unescaped match at all
7162        assert_eq!(find_unescaped(r"a\*\*b", "**"), None);
7163        // Plain match without any escaping
7164        assert_eq!(find_unescaped("a**b", "**"), Some(1));
7165        // Empty haystack
7166        assert_eq!(find_unescaped("", "**"), None);
7167    }
7168
7169    #[test]
7170    fn find_unescaped_char_skips_backslash_escaped() {
7171        // Escaped `*` should not be found
7172        assert_eq!(find_unescaped_char(r"a\*b*", b'*'), Some(4));
7173        // No unescaped match at all
7174        assert_eq!(find_unescaped_char(r"\*", b'*'), None);
7175        // Plain match
7176        assert_eq!(find_unescaped_char("a*b", b'*'), Some(1));
7177        // Empty haystack
7178        assert_eq!(find_unescaped_char("", b'*'), None);
7179    }
7180
7181    #[test]
7182    fn double_asterisk_in_strong_mark_roundtrip() {
7183        // Text with ** inside a strong mark should preserve the literal **
7184        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"call **kwargs","marks":[{"type":"strong"}]}]}]}"#;
7185        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7186        let jfm = adf_to_markdown(&adf).unwrap();
7187        let roundtripped = markdown_to_adf(&jfm).unwrap();
7188        let content = roundtripped.content[0].content.as_ref().unwrap();
7189        let strong_node = content.iter().find(|n| {
7190            n.marks
7191                .as_ref()
7192                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
7193        });
7194        assert!(strong_node.is_some(), "should have a strong-marked node");
7195        assert_eq!(strong_node.unwrap().text.as_deref(), Some("call **kwargs"));
7196    }
7197
7198    #[test]
7199    fn backtick_code_roundtrip() {
7200        // The original reproducer from issue #453
7201        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Set `max_retries` to 3 in the config"}]}]}"#;
7202        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7203        let jfm = adf_to_markdown(&adf).unwrap();
7204        let roundtripped = markdown_to_adf(&jfm).unwrap();
7205        let content = roundtripped.content[0].content.as_ref().unwrap();
7206        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7207        assert_eq!(
7208            content[0].text.as_deref(),
7209            Some("Set `max_retries` to 3 in the config")
7210        );
7211        assert!(content[0].marks.is_none());
7212    }
7213
7214    #[test]
7215    fn multiple_backtick_spans_roundtrip() {
7216        // Multiple backtick-delimited spans in a single text node
7217        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use `foo` and `bar` together"}]}]}"#;
7218        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7219        let jfm = adf_to_markdown(&adf).unwrap();
7220        let roundtripped = markdown_to_adf(&jfm).unwrap();
7221        let content = roundtripped.content[0].content.as_ref().unwrap();
7222        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7223        assert_eq!(
7224            content[0].text.as_deref(),
7225            Some("Use `foo` and `bar` together")
7226        );
7227        assert!(content[0].marks.is_none());
7228    }
7229
7230    #[test]
7231    fn lone_backtick_roundtrip() {
7232        // A single backtick that cannot form a code span should round-trip
7233        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use a ` character"}]}]}"#;
7234        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7235        let jfm = adf_to_markdown(&adf).unwrap();
7236        let roundtripped = markdown_to_adf(&jfm).unwrap();
7237        let content = roundtripped.content[0].content.as_ref().unwrap();
7238        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7239        assert_eq!(content[0].text.as_deref(), Some("Use a ` character"));
7240        assert!(content[0].marks.is_none());
7241    }
7242
7243    #[test]
7244    fn backtick_with_code_mark_roundtrip() {
7245        // Text that already has a code mark should preserve both the mark and content
7246        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"max_retries","marks":[{"type":"code"}]}]}]}"#;
7247        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7248        let jfm = adf_to_markdown(&adf).unwrap();
7249        assert_eq!(jfm.trim(), "`max_retries`");
7250        let roundtripped = markdown_to_adf(&jfm).unwrap();
7251        let content = roundtripped.content[0].content.as_ref().unwrap();
7252        let code_node = content.iter().find(|n| {
7253            n.marks
7254                .as_ref()
7255                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code"))
7256        });
7257        assert!(code_node.is_some(), "should have a code-marked node");
7258        assert_eq!(code_node.unwrap().text.as_deref(), Some("max_retries"));
7259    }
7260
7261    #[test]
7262    fn backtick_with_em_mark_roundtrip() {
7263        // Backtick inside em-marked text should preserve both
7264        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"use `cfg`","marks":[{"type":"em"}]}]}]}"#;
7265        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7266        let jfm = adf_to_markdown(&adf).unwrap();
7267        let roundtripped = markdown_to_adf(&jfm).unwrap();
7268        let content = roundtripped.content[0].content.as_ref().unwrap();
7269        let em_node = content.iter().find(|n| {
7270            n.marks
7271                .as_ref()
7272                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "em"))
7273        });
7274        assert!(em_node.is_some(), "should have an em-marked node");
7275        assert_eq!(em_node.unwrap().text.as_deref(), Some("use `cfg`"));
7276    }
7277
7278    #[test]
7279    fn escape_pipes_in_cell_unit() {
7280        assert_eq!(escape_pipes_in_cell("hello"), "hello");
7281        assert_eq!(escape_pipes_in_cell("a|b"), r"a\|b");
7282        assert_eq!(escape_pipes_in_cell("|"), r"\|");
7283        assert_eq!(escape_pipes_in_cell("|a|b|"), r"\|a\|b\|");
7284        assert_eq!(escape_pipes_in_cell(""), "");
7285        assert_eq!(
7286            escape_pipes_in_cell("`parser.decode[T|json]`"),
7287            r"`parser.decode[T\|json]`"
7288        );
7289    }
7290
7291    #[test]
7292    fn escape_backticks_unit() {
7293        assert_eq!(escape_backticks("hello"), "hello");
7294        assert_eq!(escape_backticks("`code`"), r"\`code\`");
7295        assert_eq!(escape_backticks("no ticks"), "no ticks");
7296        assert_eq!(escape_backticks("a ` b"), r"a \` b");
7297        assert_eq!(escape_backticks(""), "");
7298        assert_eq!(escape_backticks("`a` and `b`"), r"\`a\` and \`b\`");
7299    }
7300
7301    // ── Issue #477: backslash escaping ──────────────────────────────
7302
7303    #[test]
7304    fn backslash_in_text_roundtrip() {
7305        // The original reproducer from issue #477
7306        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"The path is C:\\Users\\admin\\file.txt"}]}]}"#;
7307        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7308        let jfm = adf_to_markdown(&adf).unwrap();
7309        let roundtripped = markdown_to_adf(&jfm).unwrap();
7310        let content = roundtripped.content[0].content.as_ref().unwrap();
7311        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7312        assert_eq!(
7313            content[0].text.as_deref(),
7314            Some(r"The path is C:\Users\admin\file.txt")
7315        );
7316    }
7317
7318    #[test]
7319    fn backslash_emitted_as_double_backslash() {
7320        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a\\b"}]}]}"#;
7321        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7322        let jfm = adf_to_markdown(&adf).unwrap();
7323        assert!(
7324            jfm.contains(r"a\\b"),
7325            "JFM should contain escaped backslash: {jfm}"
7326        );
7327    }
7328
7329    #[test]
7330    fn consecutive_backslashes_roundtrip() {
7331        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a\\\\b"}]}]}"#;
7332        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7333        let jfm = adf_to_markdown(&adf).unwrap();
7334        let roundtripped = markdown_to_adf(&jfm).unwrap();
7335        let content = roundtripped.content[0].content.as_ref().unwrap();
7336        assert_eq!(
7337            content[0].text.as_deref(),
7338            Some(r"a\\b"),
7339            "consecutive backslashes should survive round-trip"
7340        );
7341    }
7342
7343    #[test]
7344    fn backslash_with_strong_mark_roundtrip() {
7345        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\Users","marks":[{"type":"strong"}]}]}]}"#;
7346        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7347        let jfm = adf_to_markdown(&adf).unwrap();
7348        let roundtripped = markdown_to_adf(&jfm).unwrap();
7349        let content = roundtripped.content[0].content.as_ref().unwrap();
7350        let strong_node = content.iter().find(|n| {
7351            n.marks
7352                .as_ref()
7353                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
7354        });
7355        assert!(strong_node.is_some(), "should have a strong-marked node");
7356        assert_eq!(strong_node.unwrap().text.as_deref(), Some(r"C:\Users"));
7357    }
7358
7359    #[test]
7360    fn backslash_with_code_mark_not_escaped() {
7361        // Code marks emit content verbatim — backslashes should NOT be escaped
7362        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\Users","marks":[{"type":"code"}]}]}]}"#;
7363        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7364        let jfm = adf_to_markdown(&adf).unwrap();
7365        assert_eq!(jfm.trim(), r"`C:\Users`");
7366        let roundtripped = markdown_to_adf(&jfm).unwrap();
7367        let content = roundtripped.content[0].content.as_ref().unwrap();
7368        let code_node = content.iter().find(|n| {
7369            n.marks
7370                .as_ref()
7371                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code"))
7372        });
7373        assert!(code_node.is_some(), "should have a code-marked node");
7374        assert_eq!(code_node.unwrap().text.as_deref(), Some(r"C:\Users"));
7375    }
7376
7377    #[test]
7378    fn backslash_before_special_chars_roundtrip() {
7379        // Backslash before characters that are themselves escaped (* ` :)
7380        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"\\*not bold\\*"}]}]}"#;
7381        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7382        let jfm = adf_to_markdown(&adf).unwrap();
7383        let roundtripped = markdown_to_adf(&jfm).unwrap();
7384        let content = roundtripped.content[0].content.as_ref().unwrap();
7385        assert_eq!(
7386            content[0].text.as_deref(),
7387            Some(r"\*not bold\*"),
7388            "backslash before special char should survive round-trip"
7389        );
7390    }
7391
7392    #[test]
7393    fn backslash_and_newline_in_text_roundtrip() {
7394        // Text with both backslashes and embedded newlines
7395        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\path\nline2"}]}]}"#;
7396        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7397        let jfm = adf_to_markdown(&adf).unwrap();
7398        let roundtripped = markdown_to_adf(&jfm).unwrap();
7399        let content = roundtripped.content[0].content.as_ref().unwrap();
7400        assert_eq!(
7401            content[0].text.as_deref(),
7402            Some("C:\\path\nline2"),
7403            "backslash and newline should both survive round-trip"
7404        );
7405    }
7406
7407    #[test]
7408    fn lone_backslash_roundtrip() {
7409        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a \\ b"}]}]}"#;
7410        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7411        let jfm = adf_to_markdown(&adf).unwrap();
7412        let roundtripped = markdown_to_adf(&jfm).unwrap();
7413        let content = roundtripped.content[0].content.as_ref().unwrap();
7414        assert_eq!(content[0].text.as_deref(), Some(r"a \ b"));
7415    }
7416
7417    #[test]
7418    fn trailing_backslash_in_text_roundtrip() {
7419        // A trailing backslash in text content (not a hardBreak) should round-trip
7420        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"end\\"}]}]}"#;
7421        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7422        let jfm = adf_to_markdown(&adf).unwrap();
7423        let roundtripped = markdown_to_adf(&jfm).unwrap();
7424        let content = roundtripped.content[0].content.as_ref().unwrap();
7425        assert_eq!(
7426            content[0].text.as_deref(),
7427            Some(r"end\"),
7428            "trailing backslash should survive round-trip"
7429        );
7430    }
7431
7432    #[test]
7433    fn escape_bare_urls_unit() {
7434        assert_eq!(escape_bare_urls("hello"), "hello");
7435        assert_eq!(escape_bare_urls(""), "");
7436        assert_eq!(
7437            escape_bare_urls("https://example.com"),
7438            r"\https://example.com"
7439        );
7440        assert_eq!(
7441            escape_bare_urls("http://example.com"),
7442            r"\http://example.com"
7443        );
7444        assert_eq!(
7445            escape_bare_urls("see https://a.com and https://b.com"),
7446            r"see \https://a.com and \https://b.com"
7447        );
7448        // "http" without "://" is not a URL scheme — leave untouched
7449        assert_eq!(escape_bare_urls("http header"), "http header");
7450        assert_eq!(escape_bare_urls("https is secure"), "https is secure");
7451    }
7452
7453    #[test]
7454    fn heading_not_valid_without_space() {
7455        // "#Title" without space should be a paragraph, not heading
7456        let doc = markdown_to_adf("#Title").unwrap();
7457        assert_eq!(doc.content[0].node_type, "paragraph");
7458    }
7459
7460    #[test]
7461    fn heading_level_too_high() {
7462        // ####### (7 hashes) is not a valid heading
7463        let doc = markdown_to_adf("####### Not a heading").unwrap();
7464        assert_eq!(doc.content[0].node_type, "paragraph");
7465    }
7466
7467    #[test]
7468    fn empty_document() {
7469        let doc = markdown_to_adf("").unwrap();
7470        assert!(doc.content.is_empty());
7471    }
7472
7473    #[test]
7474    fn only_blank_lines() {
7475        let doc = markdown_to_adf("\n\n\n").unwrap();
7476        assert!(doc.content.is_empty());
7477    }
7478
7479    #[test]
7480    fn code_block_unterminated() {
7481        // Code block without closing fence
7482        let md = "```rust\nfn main() {}";
7483        let doc = markdown_to_adf(md).unwrap();
7484        assert_eq!(doc.content[0].node_type, "codeBlock");
7485    }
7486
7487    #[test]
7488    fn mixed_document() {
7489        let md = "# Title\n\nA paragraph.\n\n- Item\n\n```\ncode\n```\n\n> quote\n\n---\n\n1. numbered\n";
7490        let doc = markdown_to_adf(md).unwrap();
7491        let types: Vec<&str> = doc.content.iter().map(|n| n.node_type.as_str()).collect();
7492        assert_eq!(
7493            types,
7494            vec![
7495                "heading",
7496                "paragraph",
7497                "bulletList",
7498                "codeBlock",
7499                "blockquote",
7500                "rule",
7501                "orderedList",
7502            ]
7503        );
7504    }
7505
7506    // ── Additional adf_to_markdown tests ───────────────────────────────
7507
7508    #[test]
7509    fn adf_ordered_list_to_markdown() {
7510        let doc = AdfDocument {
7511            version: 1,
7512            doc_type: "doc".to_string(),
7513            content: vec![AdfNode::ordered_list(
7514                vec![
7515                    AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("First")])]),
7516                    AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("Second")])]),
7517                ],
7518                None,
7519            )],
7520        };
7521        let md = adf_to_markdown(&doc).unwrap();
7522        assert!(md.contains("1. First"));
7523        assert!(md.contains("2. Second"));
7524    }
7525
7526    #[test]
7527    fn adf_ordered_list_custom_start() {
7528        let doc = AdfDocument {
7529            version: 1,
7530            doc_type: "doc".to_string(),
7531            content: vec![AdfNode::ordered_list(
7532                vec![AdfNode::list_item(vec![AdfNode::paragraph(vec![
7533                    AdfNode::text("Third"),
7534                ])])],
7535                Some(3),
7536            )],
7537        };
7538        let md = adf_to_markdown(&doc).unwrap();
7539        assert!(md.contains("3. Third"));
7540    }
7541
7542    #[test]
7543    fn adf_blockquote_to_markdown() {
7544        let doc = AdfDocument {
7545            version: 1,
7546            doc_type: "doc".to_string(),
7547            content: vec![AdfNode::blockquote(vec![AdfNode::paragraph(vec![
7548                AdfNode::text("A quote"),
7549            ])])],
7550        };
7551        let md = adf_to_markdown(&doc).unwrap();
7552        assert!(md.contains("> A quote"));
7553    }
7554
7555    #[test]
7556    fn adf_table_to_markdown() {
7557        let doc = AdfDocument {
7558            version: 1,
7559            doc_type: "doc".to_string(),
7560            content: vec![AdfNode::table(vec![
7561                AdfNode::table_row(vec![
7562                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("Name")])]),
7563                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("Value")])]),
7564                ]),
7565                AdfNode::table_row(vec![
7566                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("a")])]),
7567                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("1")])]),
7568                ]),
7569            ])],
7570        };
7571        let md = adf_to_markdown(&doc).unwrap();
7572        assert!(md.contains("| Name | Value |"));
7573        assert!(md.contains("| --- | --- |"));
7574        assert!(md.contains("| a | 1 |"));
7575    }
7576
7577    #[test]
7578    fn adf_media_to_markdown() {
7579        let doc = AdfDocument {
7580            version: 1,
7581            doc_type: "doc".to_string(),
7582            content: vec![AdfNode::media_single(
7583                "https://example.com/img.png",
7584                Some("Alt"),
7585            )],
7586        };
7587        let md = adf_to_markdown(&doc).unwrap();
7588        assert!(md.contains("![Alt](https://example.com/img.png)"));
7589    }
7590
7591    #[test]
7592    fn adf_media_no_alt_to_markdown() {
7593        let doc = AdfDocument {
7594            version: 1,
7595            doc_type: "doc".to_string(),
7596            content: vec![AdfNode::media_single("https://example.com/img.png", None)],
7597        };
7598        let md = adf_to_markdown(&doc).unwrap();
7599        assert!(md.contains("![](https://example.com/img.png)"));
7600    }
7601
7602    #[test]
7603    fn adf_italic_to_markdown() {
7604        let doc = AdfDocument {
7605            version: 1,
7606            doc_type: "doc".to_string(),
7607            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7608                "emphasis",
7609                vec![AdfMark::em()],
7610            )])],
7611        };
7612        let md = adf_to_markdown(&doc).unwrap();
7613        assert_eq!(md.trim(), "*emphasis*");
7614    }
7615
7616    #[test]
7617    fn adf_strikethrough_to_markdown() {
7618        let doc = AdfDocument {
7619            version: 1,
7620            doc_type: "doc".to_string(),
7621            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7622                "deleted",
7623                vec![AdfMark::strike()],
7624            )])],
7625        };
7626        let md = adf_to_markdown(&doc).unwrap();
7627        assert_eq!(md.trim(), "~~deleted~~");
7628    }
7629
7630    #[test]
7631    fn adf_inline_code_to_markdown() {
7632        let doc = AdfDocument {
7633            version: 1,
7634            doc_type: "doc".to_string(),
7635            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7636                "code",
7637                vec![AdfMark::code()],
7638            )])],
7639        };
7640        let md = adf_to_markdown(&doc).unwrap();
7641        assert_eq!(md.trim(), "`code`");
7642    }
7643
7644    #[test]
7645    fn adf_code_with_link_to_markdown() {
7646        let doc = AdfDocument {
7647            version: 1,
7648            doc_type: "doc".to_string(),
7649            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7650                "func",
7651                vec![AdfMark::code(), AdfMark::link("https://example.com")],
7652            )])],
7653        };
7654        let md = adf_to_markdown(&doc).unwrap();
7655        assert_eq!(md.trim(), "[`func`](https://example.com)");
7656    }
7657
7658    #[test]
7659    fn adf_bold_italic_to_markdown() {
7660        let doc = AdfDocument {
7661            version: 1,
7662            doc_type: "doc".to_string(),
7663            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7664                "both",
7665                vec![AdfMark::strong(), AdfMark::em()],
7666            )])],
7667        };
7668        let md = adf_to_markdown(&doc).unwrap();
7669        assert_eq!(md.trim(), "***both***");
7670    }
7671
7672    #[test]
7673    fn adf_bold_link_to_markdown() {
7674        let doc = AdfDocument {
7675            version: 1,
7676            doc_type: "doc".to_string(),
7677            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7678                "bold link",
7679                vec![AdfMark::strong(), AdfMark::link("https://example.com")],
7680            )])],
7681        };
7682        let md = adf_to_markdown(&doc).unwrap();
7683        assert_eq!(md.trim(), "**[bold link](https://example.com)**");
7684    }
7685
7686    #[test]
7687    fn adf_strikethrough_bold_to_markdown() {
7688        let doc = AdfDocument {
7689            version: 1,
7690            doc_type: "doc".to_string(),
7691            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7692                "struck",
7693                vec![AdfMark::strike(), AdfMark::strong()],
7694            )])],
7695        };
7696        let md = adf_to_markdown(&doc).unwrap();
7697        assert_eq!(md.trim(), "~~**struck**~~");
7698    }
7699
7700    #[test]
7701    fn adf_hard_break_to_markdown() {
7702        let doc = AdfDocument {
7703            version: 1,
7704            doc_type: "doc".to_string(),
7705            content: vec![AdfNode::paragraph(vec![
7706                AdfNode::text("Line 1"),
7707                AdfNode::hard_break(),
7708                AdfNode::text("Line 2"),
7709            ])],
7710        };
7711        let md = adf_to_markdown(&doc).unwrap();
7712        assert!(md.contains("Line 1\\\n  Line 2"));
7713    }
7714
7715    #[test]
7716    #[test]
7717    fn adf_unsupported_inline_to_markdown() {
7718        let doc = AdfDocument {
7719            version: 1,
7720            doc_type: "doc".to_string(),
7721            content: vec![AdfNode::paragraph(vec![AdfNode {
7722                node_type: "unknownInline".to_string(),
7723                attrs: None,
7724                content: None,
7725                text: None,
7726                marks: None,
7727                local_id: None,
7728                parameters: None,
7729            }])],
7730        };
7731        let md = adf_to_markdown(&doc).unwrap();
7732        assert!(md.contains("<!-- unsupported inline: unknownInline -->"));
7733    }
7734
7735    // ── mediaInline tests (issue #476) ─────────────────────────────────
7736
7737    #[test]
7738    fn adf_media_inline_to_markdown() {
7739        let doc = AdfDocument {
7740            version: 1,
7741            doc_type: "doc".to_string(),
7742            content: vec![AdfNode::paragraph(vec![
7743                AdfNode::text("see "),
7744                AdfNode::media_inline(serde_json::json!({
7745                    "type": "image",
7746                    "id": "abcdef01-2345-6789-abcd-abcdef012345",
7747                    "collection": "contentId-111111",
7748                    "width": 200,
7749                    "height": 100
7750                })),
7751                AdfNode::text(" for details"),
7752            ])],
7753        };
7754        let md = adf_to_markdown(&doc).unwrap();
7755        assert!(md.contains(":media-inline[]{"), "got: {md}");
7756        assert!(md.contains("type=image"));
7757        assert!(md.contains("id=abcdef01-2345-6789-abcd-abcdef012345"));
7758        assert!(md.contains("collection=contentId-111111"));
7759        assert!(md.contains("width=200"));
7760        assert!(md.contains("height=100"));
7761        assert!(!md.contains("<!-- unsupported inline"));
7762    }
7763
7764    #[test]
7765    fn media_inline_round_trip() {
7766        let doc = AdfDocument {
7767            version: 1,
7768            doc_type: "doc".to_string(),
7769            content: vec![AdfNode::paragraph(vec![
7770                AdfNode::text("see "),
7771                AdfNode::media_inline(serde_json::json!({
7772                    "type": "image",
7773                    "id": "abcdef01-2345-6789-abcd-abcdef012345",
7774                    "collection": "contentId-111111",
7775                    "width": 200,
7776                    "height": 100
7777                })),
7778                AdfNode::text(" for details"),
7779            ])],
7780        };
7781        let md = adf_to_markdown(&doc).unwrap();
7782        let rt = markdown_to_adf(&md).unwrap();
7783
7784        let content = rt.content[0].content.as_ref().unwrap();
7785        assert_eq!(content[0].text.as_deref(), Some("see "));
7786        assert_eq!(content[1].node_type, "mediaInline");
7787        let attrs = content[1].attrs.as_ref().unwrap();
7788        assert_eq!(attrs["type"], "image");
7789        assert_eq!(attrs["id"], "abcdef01-2345-6789-abcd-abcdef012345");
7790        assert_eq!(attrs["collection"], "contentId-111111");
7791        assert_eq!(attrs["width"], 200);
7792        assert_eq!(attrs["height"], 100);
7793        assert_eq!(content[2].text.as_deref(), Some(" for details"));
7794    }
7795
7796    #[test]
7797    fn media_inline_external_url_round_trip() {
7798        let doc = AdfDocument {
7799            version: 1,
7800            doc_type: "doc".to_string(),
7801            content: vec![AdfNode::paragraph(vec![AdfNode::media_inline(
7802                serde_json::json!({
7803                    "type": "external",
7804                    "url": "https://example.com/image.png",
7805                    "alt": "example",
7806                    "width": 400,
7807                    "height": 300
7808                }),
7809            )])],
7810        };
7811        let md = adf_to_markdown(&doc).unwrap();
7812        let rt = markdown_to_adf(&md).unwrap();
7813
7814        let content = rt.content[0].content.as_ref().unwrap();
7815        assert_eq!(content[0].node_type, "mediaInline");
7816        let attrs = content[0].attrs.as_ref().unwrap();
7817        assert_eq!(attrs["type"], "external");
7818        assert_eq!(attrs["url"], "https://example.com/image.png");
7819        assert_eq!(attrs["alt"], "example");
7820        assert_eq!(attrs["width"], 400);
7821        assert_eq!(attrs["height"], 300);
7822    }
7823
7824    #[test]
7825    fn media_inline_minimal_attrs() {
7826        let doc = AdfDocument {
7827            version: 1,
7828            doc_type: "doc".to_string(),
7829            content: vec![AdfNode::paragraph(vec![AdfNode::media_inline(
7830                serde_json::json!({"type": "file", "id": "abc-123"}),
7831            )])],
7832        };
7833        let md = adf_to_markdown(&doc).unwrap();
7834        let rt = markdown_to_adf(&md).unwrap();
7835
7836        let content = rt.content[0].content.as_ref().unwrap();
7837        assert_eq!(content[0].node_type, "mediaInline");
7838        let attrs = content[0].attrs.as_ref().unwrap();
7839        assert_eq!(attrs["type"], "file");
7840        assert_eq!(attrs["id"], "abc-123");
7841    }
7842
7843    #[test]
7844    fn media_inline_from_issue_476_reproducer() {
7845        // Exact reproducer from issue #476
7846        let adf_json: serde_json::Value = serde_json::json!({
7847            "type": "doc",
7848            "version": 1,
7849            "content": [
7850                {
7851                    "type": "paragraph",
7852                    "content": [
7853                        {"type": "text", "text": "see "},
7854                        {
7855                            "type": "mediaInline",
7856                            "attrs": {
7857                                "collection": "contentId-111111",
7858                                "height": 100,
7859                                "id": "abcdef01-2345-6789-abcd-abcdef012345",
7860                                "localId": "aabbccdd-1234-5678-abcd-aabbccdd1234",
7861                                "type": "image",
7862                                "width": 200
7863                            }
7864                        },
7865                        {"type": "text", "text": " for details"}
7866                    ]
7867                }
7868            ]
7869        });
7870        let doc: AdfDocument = serde_json::from_value(adf_json).unwrap();
7871        let md = adf_to_markdown(&doc).unwrap();
7872        assert!(
7873            !md.contains("<!-- unsupported inline"),
7874            "mediaInline should not be unsupported; got: {md}"
7875        );
7876
7877        let rt = markdown_to_adf(&md).unwrap();
7878        let content = rt.content[0].content.as_ref().unwrap();
7879        assert_eq!(content[1].node_type, "mediaInline");
7880        let attrs = content[1].attrs.as_ref().unwrap();
7881        assert_eq!(attrs["type"], "image");
7882        assert_eq!(attrs["id"], "abcdef01-2345-6789-abcd-abcdef012345");
7883        assert_eq!(attrs["collection"], "contentId-111111");
7884        assert_eq!(attrs["width"], 200);
7885        assert_eq!(attrs["height"], 100);
7886        assert_eq!(attrs["localId"], "aabbccdd-1234-5678-abcd-aabbccdd1234");
7887    }
7888
7889    #[test]
7890    fn emoji_shortcode() {
7891        let doc = markdown_to_adf("Hello :wave: world").unwrap();
7892        let content = doc.content[0].content.as_ref().unwrap();
7893        assert_eq!(content[0].text.as_deref(), Some("Hello "));
7894        assert_eq!(content[1].node_type, "emoji");
7895        assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":wave:");
7896        assert_eq!(content[2].text.as_deref(), Some(" world"));
7897    }
7898
7899    #[test]
7900    fn adf_emoji_to_markdown() {
7901        let doc = AdfDocument {
7902            version: 1,
7903            doc_type: "doc".to_string(),
7904            content: vec![AdfNode::paragraph(vec![AdfNode::emoji("thumbsup")])],
7905        };
7906        let md = adf_to_markdown(&doc).unwrap();
7907        assert!(md.contains(":thumbsup:"));
7908    }
7909
7910    #[test]
7911    fn adf_emoji_with_colon_prefix_to_markdown() {
7912        // JIRA stores shortName as ":thumbsup:" with colons
7913        let doc = AdfDocument {
7914            version: 1,
7915            doc_type: "doc".to_string(),
7916            content: vec![AdfNode::paragraph(vec![AdfNode {
7917                node_type: "emoji".to_string(),
7918                attrs: Some(serde_json::json!({"shortName": ":thumbsup:"})),
7919                content: None,
7920                text: None,
7921                marks: None,
7922                local_id: None,
7923                parameters: None,
7924            }])],
7925        };
7926        let md = adf_to_markdown(&doc).unwrap();
7927        assert!(md.contains(":thumbsup:"));
7928        // Should not produce ::thumbsup:: (double colons)
7929        assert!(!md.contains("::thumbsup::"));
7930    }
7931
7932    #[test]
7933    fn round_trip_emoji() {
7934        let md = "Hello :wave: world\n";
7935        let doc = markdown_to_adf(md).unwrap();
7936        let result = adf_to_markdown(&doc).unwrap();
7937        assert!(result.contains(":wave:"));
7938    }
7939
7940    #[test]
7941    fn emoji_with_id_and_text_round_trips() {
7942        let doc = AdfDocument {
7943            version: 1,
7944            doc_type: "doc".to_string(),
7945            content: vec![AdfNode::paragraph(vec![AdfNode {
7946                node_type: "emoji".to_string(),
7947                attrs: Some(
7948                    serde_json::json!({"shortName": ":check_mark:", "id": "2705", "text": "✅"}),
7949                ),
7950                content: None,
7951                text: None,
7952                marks: None,
7953                local_id: None,
7954                parameters: None,
7955            }])],
7956        };
7957        let md = adf_to_markdown(&doc).unwrap();
7958        assert!(md.contains(":check_mark:"), "shortcode present: {md}");
7959        assert!(md.contains("id="), "id attr present: {md}");
7960        assert!(md.contains("text="), "text attr present: {md}");
7961
7962        // Round-trip back to ADF
7963        let round_tripped = markdown_to_adf(&md).unwrap();
7964        let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
7965        let attrs = emoji.attrs.as_ref().unwrap();
7966        assert_eq!(attrs["shortName"], ":check_mark:");
7967        assert_eq!(attrs["id"], "2705");
7968        assert_eq!(attrs["text"], "✅");
7969    }
7970
7971    #[test]
7972    fn emoji_without_extra_attrs_still_works() {
7973        let md = "Hello :wave: world\n";
7974        let doc = markdown_to_adf(md).unwrap();
7975        let emoji = &doc.content[0].content.as_ref().unwrap()[1];
7976        assert_eq!(emoji.attrs.as_ref().unwrap()["shortName"], ":wave:");
7977        // No id or text attrs when not provided
7978        assert!(emoji.attrs.as_ref().unwrap().get("id").is_none());
7979    }
7980
7981    #[test]
7982    fn emoji_shortname_preserves_colons_round_trip() {
7983        // Issue #362: emoji shortName colons stripped during round-trip
7984        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
7985          {"type":"emoji","attrs":{"shortName":":cross_mark:","id":"atlassian-cross_mark","text":"❌"}}
7986        ]}]}"#;
7987        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
7988
7989        // ADF → markdown → ADF round-trip
7990        let md = adf_to_markdown(&doc).unwrap();
7991        let round_tripped = markdown_to_adf(&md).unwrap();
7992        let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
7993        let attrs = emoji.attrs.as_ref().unwrap();
7994        assert_eq!(
7995            attrs["shortName"], ":cross_mark:",
7996            "shortName should preserve colons, got: {}",
7997            attrs["shortName"]
7998        );
7999        assert_eq!(attrs["id"], "atlassian-cross_mark");
8000        assert_eq!(attrs["text"], "❌");
8001    }
8002
8003    #[test]
8004    fn emoji_shortname_without_colons_preserved() {
8005        // Issue #379: shortName without colons should not gain colons
8006        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8007          {"type":"emoji","attrs":{"shortName":"white_check_mark","id":"2705","text":"✅"}}
8008        ]}]}"#;
8009        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8010        let md = adf_to_markdown(&doc).unwrap();
8011        let round_tripped = markdown_to_adf(&md).unwrap();
8012        let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
8013        let attrs = emoji.attrs.as_ref().unwrap();
8014        assert_eq!(
8015            attrs["shortName"], "white_check_mark",
8016            "shortName without colons should stay without colons, got: {}",
8017            attrs["shortName"]
8018        );
8019    }
8020
8021    #[test]
8022    fn colon_in_text_not_emoji() {
8023        // A lone colon should not trigger emoji parsing
8024        let doc = markdown_to_adf("Time is 10:30 today").unwrap();
8025        let content = doc.content[0].content.as_ref().unwrap();
8026        assert_eq!(content.len(), 1);
8027        assert_eq!(content[0].node_type, "text");
8028    }
8029
8030    #[test]
8031    fn text_with_shortcode_pattern_round_trips_as_text() {
8032        // Issue #450: `:fire:` in a text node must not become an emoji node
8033        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Alert :fire: triggered on pod:pod42"}]}]}"#;
8034        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8035
8036        let md = adf_to_markdown(&doc).unwrap();
8037        let round_tripped = markdown_to_adf(&md).unwrap();
8038        let content = round_tripped.content[0].content.as_ref().unwrap();
8039
8040        assert_eq!(
8041            content.len(),
8042            1,
8043            "should be a single text node, got: {content:?}"
8044        );
8045        assert_eq!(content[0].node_type, "text");
8046        assert_eq!(
8047            content[0].text.as_deref().unwrap(),
8048            "Alert :fire: triggered on pod:pod42"
8049        );
8050    }
8051
8052    #[test]
8053    fn double_colon_pattern_round_trips_as_text() {
8054        // Issue #450: `::Active::` should not be parsed as emoji `:Active:`
8055        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Status::Active::Running"}]}]}"#;
8056        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8057
8058        let md = adf_to_markdown(&doc).unwrap();
8059        let round_tripped = markdown_to_adf(&md).unwrap();
8060        let content = round_tripped.content[0].content.as_ref().unwrap();
8061
8062        assert_eq!(
8063            content.len(),
8064            1,
8065            "should be a single text node, got: {content:?}"
8066        );
8067        assert_eq!(content[0].node_type, "text");
8068        assert_eq!(
8069            content[0].text.as_deref().unwrap(),
8070            "Status::Active::Running"
8071        );
8072    }
8073
8074    #[test]
8075    fn real_emoji_node_still_round_trips() {
8076        // Ensure actual emoji ADF nodes still work after the escaping fix
8077        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8078          {"type":"text","text":"Hello "},
8079          {"type":"emoji","attrs":{"shortName":":fire:","id":"1f525","text":"🔥"}},
8080          {"type":"text","text":" world"}
8081        ]}]}"#;
8082        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8083
8084        let md = adf_to_markdown(&doc).unwrap();
8085        let round_tripped = markdown_to_adf(&md).unwrap();
8086        let content = round_tripped.content[0].content.as_ref().unwrap();
8087
8088        // Should have: text("Hello ") + emoji(:fire:) + text(" world")
8089        assert_eq!(content.len(), 3, "should have 3 nodes: {content:?}");
8090        assert_eq!(content[0].text.as_deref(), Some("Hello "));
8091        assert_eq!(content[1].node_type, "emoji");
8092        assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":fire:");
8093        assert_eq!(content[2].text.as_deref(), Some(" world"));
8094    }
8095
8096    #[test]
8097    fn combined_emoji_shortname_round_trips_as_single_node() {
8098        // Issue #576: an emoji node whose shortName is a combination of two
8099        // shortcodes (e.g. ":slightly_smiling_face::bow:") must survive the
8100        // ADF → markdown → ADF round-trip as a single emoji node rather than
8101        // being split into two.
8102        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8103          {"type":"text","text":"Thanks for the help "},
8104          {"type":"emoji","attrs":{"shortName":":slightly_smiling_face::bow:","id":"","text":""}}
8105        ]}]}"#;
8106        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8107
8108        let md = adf_to_markdown(&doc).unwrap();
8109        let round_tripped = markdown_to_adf(&md).unwrap();
8110        let content = round_tripped.content[0].content.as_ref().unwrap();
8111
8112        assert_eq!(
8113            content.len(),
8114            2,
8115            "should have text + single combined emoji: {content:?}"
8116        );
8117        assert_eq!(content[0].text.as_deref(), Some("Thanks for the help "));
8118        assert_eq!(content[1].node_type, "emoji");
8119        let attrs = content[1].attrs.as_ref().unwrap();
8120        assert_eq!(attrs["shortName"], ":slightly_smiling_face::bow:");
8121        assert_eq!(attrs["id"], "");
8122        assert_eq!(attrs["text"], "");
8123    }
8124
8125    #[test]
8126    fn triple_combined_emoji_shortname_round_trips_as_single_node() {
8127        // Three-part combined shortName must also survive round-trip.
8128        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8129          {"type":"emoji","attrs":{"shortName":":a::b::c:","id":"x","text":""}}
8130        ]}]}"#;
8131        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8132
8133        let md = adf_to_markdown(&doc).unwrap();
8134        let round_tripped = markdown_to_adf(&md).unwrap();
8135        let content = round_tripped.content[0].content.as_ref().unwrap();
8136
8137        assert_eq!(content.len(), 1, "should be single emoji: {content:?}");
8138        assert_eq!(content[0].node_type, "emoji");
8139        let attrs = content[0].attrs.as_ref().unwrap();
8140        assert_eq!(attrs["shortName"], ":a::b::c:");
8141        assert_eq!(attrs["id"], "x");
8142    }
8143
8144    #[test]
8145    fn consecutive_emojis_remain_separate_nodes() {
8146        // Two independent emoji nodes (each with their own directive) must
8147        // remain two separate nodes — the combined-chain logic must not
8148        // swallow the second emoji.
8149        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8150          {"type":"emoji","attrs":{"shortName":":fire:","id":"1f525","text":"🔥"}},
8151          {"type":"emoji","attrs":{"shortName":":water:","id":"1f4a7","text":"💧"}}
8152        ]}]}"#;
8153        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8154
8155        let md = adf_to_markdown(&doc).unwrap();
8156        let round_tripped = markdown_to_adf(&md).unwrap();
8157        let content = round_tripped.content[0].content.as_ref().unwrap();
8158
8159        assert_eq!(content.len(), 2, "should be two emoji nodes: {content:?}");
8160        assert_eq!(content[0].node_type, "emoji");
8161        assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":fire:");
8162        assert_eq!(content[1].node_type, "emoji");
8163        assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":water:");
8164    }
8165
8166    #[test]
8167    fn adjacent_shortcodes_without_directive_parse_as_two_emojis() {
8168        // Raw markdown with two adjacent shortcodes and no directive should
8169        // still parse as two separate emoji nodes.
8170        let md = ":fire::water:";
8171        let doc = markdown_to_adf(md).unwrap();
8172        let content = doc.content[0].content.as_ref().unwrap();
8173
8174        assert_eq!(content.len(), 2, "should be two emojis: {content:?}");
8175        assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":fire:");
8176        assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":water:");
8177    }
8178
8179    #[test]
8180    fn combined_emoji_shortname_preserves_local_id() {
8181        // The directive's localId should be preserved when the chain is
8182        // collapsed into a single emoji node.
8183        let md = r#":a::b:{shortName=":a::b:" id="x" text="y" localId="abc"}"#;
8184        let doc = markdown_to_adf(md).unwrap();
8185        let content = doc.content[0].content.as_ref().unwrap();
8186
8187        assert_eq!(content.len(), 1, "should be single emoji: {content:?}");
8188        let attrs = content[0].attrs.as_ref().unwrap();
8189        assert_eq!(attrs["shortName"], ":a::b:");
8190        assert_eq!(attrs["id"], "x");
8191        assert_eq!(attrs["text"], "y");
8192        assert_eq!(attrs["localId"], "abc");
8193    }
8194
8195    #[test]
8196    fn text_shortcode_with_marks_round_trips() {
8197        // Bold text containing an emoji-like shortcode should round-trip as bold text
8198        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8199          {"type":"text","text":"Alert :fire: triggered","marks":[{"type":"strong"}]}
8200        ]}]}"#;
8201        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8202
8203        let md = adf_to_markdown(&doc).unwrap();
8204        let round_tripped = markdown_to_adf(&md).unwrap();
8205        let content = round_tripped.content[0].content.as_ref().unwrap();
8206
8207        assert_eq!(
8208            content.len(),
8209            1,
8210            "should be single bold text node: {content:?}"
8211        );
8212        assert_eq!(content[0].node_type, "text");
8213        assert_eq!(
8214            content[0].text.as_deref().unwrap(),
8215            "Alert :fire: triggered"
8216        );
8217        assert!(content[0]
8218            .marks
8219            .as_ref()
8220            .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong")));
8221    }
8222
8223    #[test]
8224    fn mixed_emoji_node_and_text_shortcode_round_trips() {
8225        // Real emoji node adjacent to text containing a shortcode-like pattern
8226        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8227          {"type":"emoji","attrs":{"shortName":":wave:","id":"1f44b","text":"👋"}},
8228          {"type":"text","text":" says :hello: to you"}
8229        ]}]}"#;
8230        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8231
8232        let md = adf_to_markdown(&doc).unwrap();
8233        let round_tripped = markdown_to_adf(&md).unwrap();
8234        let content = round_tripped.content[0].content.as_ref().unwrap();
8235
8236        // Should be: emoji(:wave:) + text(" says :hello: to you")
8237        assert_eq!(content.len(), 2, "should have 2 nodes: {content:?}");
8238        assert_eq!(content[0].node_type, "emoji");
8239        assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":wave:");
8240        assert_eq!(content[1].node_type, "text");
8241        assert_eq!(content[1].text.as_deref().unwrap(), " says :hello: to you");
8242    }
8243
8244    #[test]
8245    fn code_block_with_shortcode_pattern_round_trips() {
8246        // Issue #552: Code block content containing `::Name::` patterns must not
8247        // be re-parsed as emoji shortcodes.
8248        let adf_json = r#"{"version":1,"type":"doc","content":[
8249          {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8250            {"type":"text","text":"module Foo::Bar::Baz\n  def hello\n    puts 'world'\n  end\nend"}
8251          ]}
8252        ]}"#;
8253        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8254
8255        let md = adf_to_markdown(&doc).unwrap();
8256        let round_tripped = markdown_to_adf(&md).unwrap();
8257
8258        assert_eq!(
8259            round_tripped.content.len(),
8260            1,
8261            "should be a single codeBlock"
8262        );
8263        let cb = &round_tripped.content[0];
8264        assert_eq!(cb.node_type, "codeBlock");
8265        let content = cb.content.as_ref().expect("codeBlock content");
8266        assert_eq!(
8267            content.len(),
8268            1,
8269            "should be a single text node: {content:?}"
8270        );
8271        assert_eq!(content[0].node_type, "text");
8272        assert_eq!(
8273            content[0].text.as_deref().unwrap(),
8274            "module Foo::Bar::Baz\n  def hello\n    puts 'world'\n  end\nend"
8275        );
8276        assert!(
8277            content.iter().all(|n| n.node_type != "emoji"),
8278            "no emoji nodes should be present, got: {content:?}"
8279        );
8280    }
8281
8282    #[test]
8283    fn code_block_with_exact_zendesk_shortcode_pattern_round_trips() {
8284        // Issue #552: Use the exact pattern from the bug report.
8285        let adf_json = r#"{"version":1,"type":"doc","content":[
8286          {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8287            {"type":"text","text":"class ZBC::Zendesk::PlanType::Professional < Base"}
8288          ]}
8289        ]}"#;
8290        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8291
8292        let md = adf_to_markdown(&doc).unwrap();
8293        let round_tripped = markdown_to_adf(&md).unwrap();
8294
8295        let cb = &round_tripped.content[0];
8296        assert_eq!(cb.node_type, "codeBlock");
8297        let content = cb.content.as_ref().expect("codeBlock content");
8298        assert_eq!(content.len(), 1, "should be a single text node");
8299        assert_eq!(
8300            content[0].text.as_deref().unwrap(),
8301            "class ZBC::Zendesk::PlanType::Professional < Base"
8302        );
8303    }
8304
8305    #[test]
8306    fn code_block_with_literal_shortcode_round_trips() {
8307        // Issue #552: Content that is exactly a shortcode (`:fire:`) inside a
8308        // code block should survive the round-trip as literal text, not emoji.
8309        let adf_json = r#"{"version":1,"type":"doc","content":[
8310          {"type":"codeBlock","attrs":{"language":"text"},"content":[
8311            {"type":"text","text":":fire: :wave: :thumbsup:"}
8312          ]}
8313        ]}"#;
8314        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8315
8316        let md = adf_to_markdown(&doc).unwrap();
8317        let round_tripped = markdown_to_adf(&md).unwrap();
8318
8319        let cb = &round_tripped.content[0];
8320        assert_eq!(cb.node_type, "codeBlock");
8321        let content = cb.content.as_ref().expect("codeBlock content");
8322        assert_eq!(
8323            content.len(),
8324            1,
8325            "should be a single text node: {content:?}"
8326        );
8327        assert_eq!(content[0].node_type, "text");
8328        assert_eq!(
8329            content[0].text.as_deref().unwrap(),
8330            ":fire: :wave: :thumbsup:"
8331        );
8332    }
8333
8334    #[test]
8335    fn inline_code_with_shortcode_pattern_round_trips() {
8336        // Issue #552: Inline code containing `::Name::` patterns must not be
8337        // re-parsed as emoji shortcodes.
8338        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8339          {"type":"text","text":"See "},
8340          {"type":"text","text":"Foo::Bar::Baz","marks":[{"type":"code"}]},
8341          {"type":"text","text":" for details"}
8342        ]}]}"#;
8343        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8344
8345        let md = adf_to_markdown(&doc).unwrap();
8346        let round_tripped = markdown_to_adf(&md).unwrap();
8347        let content = round_tripped.content[0].content.as_ref().unwrap();
8348
8349        assert_eq!(content.len(), 3, "should have 3 text nodes: {content:?}");
8350        assert_eq!(content[0].text.as_deref(), Some("See "));
8351        assert_eq!(content[1].text.as_deref(), Some("Foo::Bar::Baz"));
8352        assert!(content[1]
8353            .marks
8354            .as_ref()
8355            .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
8356        assert_eq!(content[2].text.as_deref(), Some(" for details"));
8357        assert!(
8358            content.iter().all(|n| n.node_type != "emoji"),
8359            "no emoji nodes should be present"
8360        );
8361    }
8362
8363    #[test]
8364    fn inline_code_with_literal_shortcode_round_trips() {
8365        // Issue #552: Inline code whose content is exactly a shortcode must be
8366        // preserved as code, not converted to an emoji.
8367        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8368          {"type":"text","text":":fire:","marks":[{"type":"code"}]}
8369        ]}]}"#;
8370        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8371
8372        let md = adf_to_markdown(&doc).unwrap();
8373        let round_tripped = markdown_to_adf(&md).unwrap();
8374        let content = round_tripped.content[0].content.as_ref().unwrap();
8375
8376        assert_eq!(
8377            content.len(),
8378            1,
8379            "should be a single code node: {content:?}"
8380        );
8381        assert_eq!(content[0].node_type, "text");
8382        assert_eq!(content[0].text.as_deref(), Some(":fire:"));
8383        assert!(content[0]
8384            .marks
8385            .as_ref()
8386            .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
8387    }
8388
8389    #[test]
8390    fn code_block_in_list_with_shortcode_pattern_round_trips() {
8391        // Issue #552: A code block containing shortcode-like patterns nested in
8392        // a list should still survive round-trip without emoji corruption.
8393        let adf_json = r#"{"version":1,"type":"doc","content":[
8394          {"type":"bulletList","content":[
8395            {"type":"listItem","content":[
8396              {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8397                {"type":"text","text":"Foo::Bar::Baz"}
8398              ]}
8399            ]}
8400          ]}
8401        ]}"#;
8402        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8403
8404        let md = adf_to_markdown(&doc).unwrap();
8405        let round_tripped = markdown_to_adf(&md).unwrap();
8406
8407        let list = &round_tripped.content[0];
8408        assert_eq!(list.node_type, "bulletList");
8409        let item = &list.content.as_ref().unwrap()[0];
8410        assert_eq!(item.node_type, "listItem");
8411        let cb = &item.content.as_ref().unwrap()[0];
8412        assert_eq!(cb.node_type, "codeBlock");
8413        let cb_content = cb.content.as_ref().unwrap();
8414        assert_eq!(cb_content[0].text.as_deref(), Some("Foo::Bar::Baz"));
8415        assert_eq!(cb_content[0].node_type, "text");
8416    }
8417
8418    #[test]
8419    fn code_block_with_unicode_shortcode_pattern_round_trips() {
8420        // Issue #552: Code block content with non-ASCII colon-delimited words
8421        // (e.g. CJK or accented characters) must round-trip without splitting
8422        // or emoji corruption.
8423        let adf_json = r#"{"version":1,"type":"doc","content":[
8424          {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8425            {"type":"text","text":"class ZBC::配置::Production < Base"}
8426          ]}
8427        ]}"#;
8428        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8429
8430        let md = adf_to_markdown(&doc).unwrap();
8431        let round_tripped = markdown_to_adf(&md).unwrap();
8432
8433        let cb = &round_tripped.content[0];
8434        assert_eq!(cb.node_type, "codeBlock");
8435        let content = cb.content.as_ref().expect("codeBlock content");
8436        assert_eq!(content.len(), 1);
8437        assert_eq!(
8438            content[0].text.as_deref().unwrap(),
8439            "class ZBC::配置::Production < Base"
8440        );
8441    }
8442
8443    #[test]
8444    fn list_item_hardbreak_then_code_block_round_trips() {
8445        // Issue #552: A list item whose first paragraph ends with a hardBreak
8446        // followed by a codeBlock must round-trip correctly.  Previously, the
8447        // hardBreak's `\` continuation swallowed the 2-space-indented code
8448        // fence line, turning the whole block into a paragraph where `::Bar::`
8449        // was re-parsed as an emoji.
8450        let adf_json = r#"{"version":1,"type":"doc","content":[
8451          {"type":"bulletList","content":[
8452            {"type":"listItem","content":[
8453              {"type":"paragraph","content":[
8454                {"type":"text","text":"Consider removing this check."},
8455                {"type":"hardBreak"}
8456              ]},
8457              {"type":"codeBlock","content":[
8458                {"type":"text","text":"x = Foo::Bar::Baz.new"}
8459              ]}
8460            ]}
8461          ]}
8462        ]}"#;
8463        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8464
8465        let md = adf_to_markdown(&doc).unwrap();
8466        let round_tripped = markdown_to_adf(&md).unwrap();
8467
8468        let list = &round_tripped.content[0];
8469        assert_eq!(list.node_type, "bulletList");
8470        let item = &list.content.as_ref().unwrap()[0];
8471        assert_eq!(item.node_type, "listItem");
8472        let item_content = item.content.as_ref().unwrap();
8473        assert_eq!(
8474            item_content.len(),
8475            2,
8476            "listItem should have paragraph + codeBlock, got: {item_content:?}"
8477        );
8478        assert_eq!(item_content[0].node_type, "paragraph");
8479        assert_eq!(item_content[1].node_type, "codeBlock");
8480
8481        // The code block text must survive verbatim — no emoji splitting.
8482        let cb_content = item_content[1].content.as_ref().unwrap();
8483        assert_eq!(cb_content[0].node_type, "text");
8484        assert_eq!(
8485            cb_content[0].text.as_deref().unwrap(),
8486            "x = Foo::Bar::Baz.new"
8487        );
8488
8489        // And there should be no emoji node anywhere in the tree.
8490        assert!(
8491            item_content
8492                .iter()
8493                .flat_map(|n| n.content.iter().flat_map(|c| c.iter()))
8494                .all(|n| n.node_type != "emoji"),
8495            "no emoji nodes should be present, got: {item_content:?}"
8496        );
8497    }
8498
8499    #[test]
8500    fn list_item_hardbreak_then_nested_list_still_works() {
8501        // Ensure the hardBreak continuation fix doesn't break nested list
8502        // handling — an indented `- item` line after a hardBreak is still a
8503        // nested list, not a code fence.
8504        let md = "- first\\\n  continuation text\\\n  - nested item\n";
8505        let doc = markdown_to_adf(md).unwrap();
8506        let list = &doc.content[0];
8507        assert_eq!(list.node_type, "bulletList");
8508        let item = &list.content.as_ref().unwrap()[0];
8509        // First paragraph should contain the continuation text joined via hardBreaks
8510        let item_content = item.content.as_ref().unwrap();
8511        let para = &item_content[0];
8512        assert_eq!(para.node_type, "paragraph");
8513        let para_nodes = para.content.as_ref().unwrap();
8514        assert!(
8515            para_nodes
8516                .iter()
8517                .any(|n| n.text.as_deref() == Some("continuation text")),
8518            "continuation text should survive: {para_nodes:?}"
8519        );
8520    }
8521
8522    #[test]
8523    fn list_item_hardbreak_then_image_still_works() {
8524        // Regression check for issue #490: the image-skip behaviour in
8525        // collect_hardbreak_continuations must still hold after the code-fence
8526        // fix.  The `![](url)` line must remain a block-level mediaSingle, not
8527        // be swallowed into the paragraph.
8528        let md = "- first\\\n  ![](https://example.com/x.png){type=file id=x}\n";
8529        let doc = markdown_to_adf(md).unwrap();
8530        let list = &doc.content[0];
8531        let item = &list.content.as_ref().unwrap()[0];
8532        let item_content = item.content.as_ref().unwrap();
8533        // The image should be a sibling block, not part of the first paragraph.
8534        assert!(
8535            item_content.iter().any(|n| n.node_type == "mediaSingle"),
8536            "mediaSingle should still be a block-level sibling, got: {item_content:?}"
8537        );
8538    }
8539
8540    #[test]
8541    fn list_item_hardbreak_then_container_directive_round_trips() {
8542        // Issue #552: Same hardBreak-swallows-block-siblings bug class — a
8543        // container directive (`:::panel`) after a hardBreak must also not be
8544        // consumed as a continuation line.
8545        let adf_json = r#"{"version":1,"type":"doc","content":[
8546          {"type":"bulletList","content":[
8547            {"type":"listItem","content":[
8548              {"type":"paragraph","content":[
8549                {"type":"text","text":"intro"},
8550                {"type":"hardBreak"}
8551              ]},
8552              {"type":"panel","attrs":{"panelType":"info"},"content":[
8553                {"type":"paragraph","content":[
8554                  {"type":"text","text":"panel body"}
8555                ]}
8556              ]}
8557            ]}
8558          ]}
8559        ]}"#;
8560        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8561        let md = adf_to_markdown(&doc).unwrap();
8562        let round_tripped = markdown_to_adf(&md).unwrap();
8563
8564        let item = &round_tripped.content[0].content.as_ref().unwrap()[0];
8565        let item_content = item.content.as_ref().unwrap();
8566        assert!(
8567            item_content.iter().any(|n| n.node_type == "panel"),
8568            "panel should survive as block-level sibling, got: {item_content:?}"
8569        );
8570    }
8571
8572    #[test]
8573    fn inline_code_with_unicode_shortcode_pattern_round_trips() {
8574        // Issue #552: Inline code containing non-ASCII colon-delimited words
8575        // must round-trip without emoji corruption.
8576        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8577          {"type":"text","text":"See "},
8578          {"type":"text","text":"ZBC::配置::Production","marks":[{"type":"code"}]},
8579          {"type":"text","text":" for prod"}
8580        ]}]}"#;
8581        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8582
8583        let md = adf_to_markdown(&doc).unwrap();
8584        let round_tripped = markdown_to_adf(&md).unwrap();
8585        let content = round_tripped.content[0].content.as_ref().unwrap();
8586
8587        assert_eq!(content.len(), 3, "should have 3 nodes: {content:?}");
8588        assert_eq!(content[1].text.as_deref(), Some("ZBC::配置::Production"));
8589        assert!(content[1]
8590            .marks
8591            .as_ref()
8592            .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
8593    }
8594
8595    #[test]
8596    fn code_block_followed_by_shortcode_text_round_trips() {
8597        // Issue #552: A code block with colon-delimited content followed by a
8598        // paragraph containing emoji-like text should not confuse parsing.
8599        let adf_json = r#"{"version":1,"type":"doc","content":[
8600          {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8601            {"type":"text","text":"Foo::Bar::Baz"}
8602          ]},
8603          {"type":"paragraph","content":[
8604            {"type":"text","text":"Status::Active::Running"}
8605          ]}
8606        ]}"#;
8607        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8608
8609        let md = adf_to_markdown(&doc).unwrap();
8610        let round_tripped = markdown_to_adf(&md).unwrap();
8611
8612        assert_eq!(round_tripped.content.len(), 2);
8613        let cb = &round_tripped.content[0];
8614        assert_eq!(cb.node_type, "codeBlock");
8615        let cb_content = cb.content.as_ref().unwrap();
8616        assert_eq!(cb_content[0].text.as_deref(), Some("Foo::Bar::Baz"));
8617
8618        let para = &round_tripped.content[1];
8619        assert_eq!(para.node_type, "paragraph");
8620        let para_content = para.content.as_ref().unwrap();
8621        assert_eq!(para_content.len(), 1);
8622        assert_eq!(para_content[0].node_type, "text");
8623        assert_eq!(
8624            para_content[0].text.as_deref(),
8625            Some("Status::Active::Running")
8626        );
8627    }
8628
8629    #[test]
8630    fn adf_inline_card_to_markdown() {
8631        let doc = AdfDocument {
8632            version: 1,
8633            doc_type: "doc".to_string(),
8634            content: vec![AdfNode::paragraph(vec![AdfNode {
8635                node_type: "inlineCard".to_string(),
8636                attrs: Some(
8637                    serde_json::json!({"url": "https://org.atlassian.net/browse/ACCS-4382"}),
8638                ),
8639                content: None,
8640                text: None,
8641                marks: None,
8642                local_id: None,
8643                parameters: None,
8644            }])],
8645        };
8646        let md = adf_to_markdown(&doc).unwrap();
8647        assert!(md.contains(":card[https://org.atlassian.net/browse/ACCS-4382]"));
8648        assert!(!md.contains("<!-- unsupported inline"));
8649    }
8650
8651    #[test]
8652    fn inline_card_directive_round_trips() {
8653        // inlineCard → :card[url] → inlineCard
8654        let original = AdfDocument {
8655            version: 1,
8656            doc_type: "doc".to_string(),
8657            content: vec![AdfNode::paragraph(vec![AdfNode::inline_card(
8658                "https://org.atlassian.net/browse/ACCS-4382",
8659            )])],
8660        };
8661        let md = adf_to_markdown(&original).unwrap();
8662        assert!(md.contains(":card[https://org.atlassian.net/browse/ACCS-4382]"));
8663        let restored = markdown_to_adf(&md).unwrap();
8664        let node = &restored.content[0].content.as_ref().unwrap()[0];
8665        assert_eq!(node.node_type, "inlineCard");
8666        assert_eq!(
8667            node.attrs.as_ref().unwrap()["url"],
8668            "https://org.atlassian.net/browse/ACCS-4382"
8669        );
8670    }
8671
8672    #[test]
8673    fn inline_card_directive_parsed_from_jfm() {
8674        // :card[url] in JFM → inlineCard in ADF
8675        let doc = markdown_to_adf("See :card[https://example.com/issue/123] for details.").unwrap();
8676        let nodes = doc.content[0].content.as_ref().unwrap();
8677        assert_eq!(nodes[0].node_type, "text");
8678        assert_eq!(nodes[0].text.as_deref(), Some("See "));
8679        assert_eq!(nodes[1].node_type, "inlineCard");
8680        assert_eq!(
8681            nodes[1].attrs.as_ref().unwrap()["url"],
8682            "https://example.com/issue/123"
8683        );
8684        assert_eq!(nodes[2].node_type, "text");
8685        assert_eq!(nodes[2].text.as_deref(), Some(" for details."));
8686    }
8687
8688    #[test]
8689    fn self_link_becomes_link_mark_not_inline_card() {
8690        // Issue #378: [url](url) should produce a link mark, not inlineCard.
8691        // inlineCard is only for :card[url] directives and bare URLs.
8692        let doc = markdown_to_adf("[https://example.com](https://example.com)").unwrap();
8693        let node = &doc.content[0].content.as_ref().unwrap()[0];
8694        assert_eq!(node.node_type, "text");
8695        assert_eq!(node.text.as_deref(), Some("https://example.com"));
8696        let mark = &node.marks.as_ref().unwrap()[0];
8697        assert_eq!(mark.mark_type, "link");
8698        assert_eq!(mark.attrs.as_ref().unwrap()["href"], "https://example.com");
8699    }
8700
8701    #[test]
8702    fn url_link_text_with_trailing_slash_mismatch_becomes_link_mark() {
8703        // Issue #523: [url](url/) where text and href differ only by trailing
8704        // slash should produce a text node with link mark, not an inlineCard.
8705        let doc =
8706            markdown_to_adf("[https://octopz.example.com](https://octopz.example.com/)").unwrap();
8707        let node = &doc.content[0].content.as_ref().unwrap()[0];
8708        assert_eq!(node.node_type, "text");
8709        assert_eq!(node.text.as_deref(), Some("https://octopz.example.com"));
8710        let mark = &node.marks.as_ref().unwrap()[0];
8711        assert_eq!(mark.mark_type, "link");
8712        assert_eq!(
8713            mark.attrs.as_ref().unwrap()["href"],
8714            "https://octopz.example.com/"
8715        );
8716    }
8717
8718    #[test]
8719    fn named_link_does_not_become_inline_card() {
8720        // [#4668](url) — text differs from url, stays as a link mark
8721        let doc = markdown_to_adf("[#4668](https://github.com/org/repo/pull/4668)").unwrap();
8722        let node = &doc.content[0].content.as_ref().unwrap()[0];
8723        assert_eq!(node.node_type, "text");
8724        assert_eq!(node.text.as_deref(), Some("#4668"));
8725        let mark = &node.marks.as_ref().unwrap()[0];
8726        assert_eq!(mark.mark_type, "link");
8727    }
8728
8729    #[test]
8730    fn adf_inline_card_no_url_to_markdown() {
8731        let doc = AdfDocument {
8732            version: 1,
8733            doc_type: "doc".to_string(),
8734            content: vec![AdfNode::paragraph(vec![AdfNode {
8735                node_type: "inlineCard".to_string(),
8736                attrs: Some(serde_json::json!({})),
8737                content: None,
8738                text: None,
8739                marks: None,
8740                local_id: None,
8741                parameters: None,
8742            }])],
8743        };
8744        let md = adf_to_markdown(&doc).unwrap();
8745        // No url attr — renders nothing (not a comment)
8746        assert!(!md.contains("<!-- unsupported inline"));
8747    }
8748
8749    #[test]
8750    fn adf_code_block_no_language_to_markdown() {
8751        let doc = AdfDocument {
8752            version: 1,
8753            doc_type: "doc".to_string(),
8754            content: vec![AdfNode::code_block(None, "plain code")],
8755        };
8756        let md = adf_to_markdown(&doc).unwrap();
8757        assert!(md.contains("```\n"));
8758        assert!(md.contains("plain code"));
8759    }
8760
8761    #[test]
8762    fn adf_code_block_empty_language_to_markdown() {
8763        let doc = AdfDocument {
8764            version: 1,
8765            doc_type: "doc".to_string(),
8766            content: vec![AdfNode::code_block(Some(""), "plain code")],
8767        };
8768        let md = adf_to_markdown(&doc).unwrap();
8769        assert!(md.contains("```\"\"\n"));
8770        assert!(md.contains("plain code"));
8771    }
8772
8773    // ── Additional round-trip tests ────────────────────────────────────
8774
8775    #[test]
8776    fn round_trip_table() {
8777        let md = "| A | B |\n| --- | --- |\n| 1 | 2 |\n";
8778        let adf = markdown_to_adf(md).unwrap();
8779        let restored = adf_to_markdown(&adf).unwrap();
8780        assert!(restored.contains("| A | B |"));
8781        assert!(restored.contains("| 1 | 2 |"));
8782    }
8783
8784    #[test]
8785    fn round_trip_blockquote() {
8786        let md = "> This is quoted\n";
8787        let adf = markdown_to_adf(md).unwrap();
8788        let restored = adf_to_markdown(&adf).unwrap();
8789        assert!(restored.contains("> This is quoted"));
8790    }
8791
8792    #[test]
8793    fn round_trip_image() {
8794        let md = "![Logo](https://example.com/logo.png)\n";
8795        let adf = markdown_to_adf(md).unwrap();
8796        let restored = adf_to_markdown(&adf).unwrap();
8797        assert!(restored.contains("![Logo](https://example.com/logo.png)"));
8798    }
8799
8800    #[test]
8801    fn round_trip_ordered_list() {
8802        let md = "1. A\n2. B\n3. C\n";
8803        let adf = markdown_to_adf(md).unwrap();
8804        let restored = adf_to_markdown(&adf).unwrap();
8805        assert!(restored.contains("1. A"));
8806        assert!(restored.contains("2. B"));
8807        assert!(restored.contains("3. C"));
8808    }
8809
8810    #[test]
8811    fn round_trip_inline_marks() {
8812        let md = "Text with `code` and ~~strike~~ and [link](https://x.com).\n";
8813        let adf = markdown_to_adf(md).unwrap();
8814        let restored = adf_to_markdown(&adf).unwrap();
8815        assert!(restored.contains("`code`"));
8816        assert!(restored.contains("~~strike~~"));
8817        assert!(restored.contains("[link](https://x.com)"));
8818    }
8819
8820    // ── Container directive tests (Tier 2) ───────────────────────────
8821
8822    #[test]
8823    fn panel_info() {
8824        let md = ":::panel{type=info}\nThis is informational.\n:::";
8825        let doc = markdown_to_adf(md).unwrap();
8826        assert_eq!(doc.content[0].node_type, "panel");
8827        assert_eq!(doc.content[0].attrs.as_ref().unwrap()["panelType"], "info");
8828        let inner = doc.content[0].content.as_ref().unwrap();
8829        assert_eq!(inner[0].node_type, "paragraph");
8830    }
8831
8832    #[test]
8833    fn adf_panel_to_markdown() {
8834        let doc = AdfDocument {
8835            version: 1,
8836            doc_type: "doc".to_string(),
8837            content: vec![AdfNode::panel(
8838                "warning",
8839                vec![AdfNode::paragraph(vec![AdfNode::text("Be careful.")])],
8840            )],
8841        };
8842        let md = adf_to_markdown(&doc).unwrap();
8843        assert!(md.contains(":::panel{type=warning}"));
8844        assert!(md.contains("Be careful."));
8845        assert!(md.contains(":::"));
8846    }
8847
8848    #[test]
8849    fn round_trip_panel() {
8850        let md = ":::panel{type=info}\nThis is informational.\n:::\n";
8851        let doc = markdown_to_adf(md).unwrap();
8852        let result = adf_to_markdown(&doc).unwrap();
8853        assert!(result.contains(":::panel{type=info}"));
8854        assert!(result.contains("This is informational."));
8855    }
8856
8857    #[test]
8858    fn expand_with_title() {
8859        let md = ":::expand{title=\"Click me\"}\nHidden content.\n:::";
8860        let doc = markdown_to_adf(md).unwrap();
8861        assert_eq!(doc.content[0].node_type, "expand");
8862        assert_eq!(doc.content[0].attrs.as_ref().unwrap()["title"], "Click me");
8863    }
8864
8865    #[test]
8866    fn adf_expand_to_markdown() {
8867        let doc = AdfDocument {
8868            version: 1,
8869            doc_type: "doc".to_string(),
8870            content: vec![AdfNode::expand(
8871                Some("Details"),
8872                vec![AdfNode::paragraph(vec![AdfNode::text("Inner.")])],
8873            )],
8874        };
8875        let md = adf_to_markdown(&doc).unwrap();
8876        assert!(md.contains(":::expand{title=\"Details\"}"));
8877        assert!(md.contains("Inner."));
8878    }
8879
8880    #[test]
8881    fn round_trip_expand() {
8882        let md = ":::expand{title=\"Details\"}\nInner content.\n:::\n";
8883        let doc = markdown_to_adf(md).unwrap();
8884        let result = adf_to_markdown(&doc).unwrap();
8885        assert!(result.contains(":::expand{title=\"Details\"}"));
8886        assert!(result.contains("Inner content."));
8887    }
8888
8889    #[test]
8890    fn layout_two_columns() {
8891        let md =
8892            "::::layout\n:::column{width=50}\nLeft.\n:::\n:::column{width=50}\nRight.\n:::\n::::";
8893        let doc = markdown_to_adf(md).unwrap();
8894        assert_eq!(doc.content[0].node_type, "layoutSection");
8895        let columns = doc.content[0].content.as_ref().unwrap();
8896        assert_eq!(columns.len(), 2);
8897        assert_eq!(columns[0].node_type, "layoutColumn");
8898        assert_eq!(columns[1].node_type, "layoutColumn");
8899    }
8900
8901    #[test]
8902    fn adf_layout_to_markdown() {
8903        let doc = AdfDocument {
8904            version: 1,
8905            doc_type: "doc".to_string(),
8906            content: vec![AdfNode::layout_section(vec![
8907                AdfNode::layout_column(50, vec![AdfNode::paragraph(vec![AdfNode::text("Left.")])]),
8908                AdfNode::layout_column(50, vec![AdfNode::paragraph(vec![AdfNode::text("Right.")])]),
8909            ])],
8910        };
8911        let md = adf_to_markdown(&doc).unwrap();
8912        assert!(md.contains("::::layout"));
8913        assert!(md.contains(":::column{width=50}"));
8914        assert!(md.contains("Left."));
8915        assert!(md.contains("Right."));
8916    }
8917
8918    #[test]
8919    fn layout_column_localid_roundtrip() {
8920        let adf_json = r#"{
8921            "version": 1,
8922            "type": "doc",
8923            "content": [{
8924                "type": "layoutSection",
8925                "content": [
8926                    {
8927                        "type": "layoutColumn",
8928                        "attrs": {"width": 50.0, "localId": "aabb112233cc"},
8929                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Left"}]}]
8930                    },
8931                    {
8932                        "type": "layoutColumn",
8933                        "attrs": {"width": 50.0, "localId": "ddeeff445566"},
8934                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Right"}]}]
8935                    }
8936                ]
8937            }]
8938        }"#;
8939        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8940        let md = adf_to_markdown(&doc).unwrap();
8941        assert!(
8942            md.contains("localId=aabb112233cc"),
8943            "first column localId should appear in markdown: {md}"
8944        );
8945        assert!(
8946            md.contains("localId=ddeeff445566"),
8947            "second column localId should appear in markdown: {md}"
8948        );
8949        let rt = markdown_to_adf(&md).unwrap();
8950        let cols = rt.content[0].content.as_ref().unwrap();
8951        assert_eq!(
8952            cols[0].attrs.as_ref().unwrap()["localId"],
8953            "aabb112233cc",
8954            "first column localId should round-trip"
8955        );
8956        assert_eq!(
8957            cols[1].attrs.as_ref().unwrap()["localId"],
8958            "ddeeff445566",
8959            "second column localId should round-trip"
8960        );
8961    }
8962
8963    #[test]
8964    fn layout_column_without_localid() {
8965        let md =
8966            "::::layout\n:::column{width=50}\nLeft.\n:::\n:::column{width=50}\nRight.\n:::\n::::";
8967        let doc = markdown_to_adf(md).unwrap();
8968        let cols = doc.content[0].content.as_ref().unwrap();
8969        assert!(
8970            cols[0].attrs.as_ref().unwrap().get("localId").is_none(),
8971            "column without localId should not gain one"
8972        );
8973        let md2 = adf_to_markdown(&doc).unwrap();
8974        assert!(
8975            !md2.contains("localId"),
8976            "no localId should appear in output: {md2}"
8977        );
8978    }
8979
8980    #[test]
8981    fn layout_column_localid_stripped_when_option_set() {
8982        let adf_json = r#"{
8983            "version": 1,
8984            "type": "doc",
8985            "content": [{
8986                "type": "layoutSection",
8987                "content": [{
8988                    "type": "layoutColumn",
8989                    "attrs": {"width": 50.0, "localId": "aabb112233cc"},
8990                    "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Col"}]}]
8991                }]
8992            }]
8993        }"#;
8994        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8995        let opts = RenderOptions {
8996            strip_local_ids: true,
8997            ..Default::default()
8998        };
8999        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
9000        assert!(!md.contains("localId"), "localId should be stripped: {md}");
9001    }
9002
9003    #[test]
9004    fn layout_column_localid_flush_previous() {
9005        // Columns open without explicit `:::` close → flush-previous path
9006        let md = "::::layout\n:::column{width=50 localId=aabb112233cc}\nLeft.\n:::column{width=50 localId=ddeeff445566}\nRight.\n:::\n::::";
9007        let doc = markdown_to_adf(md).unwrap();
9008        let cols = doc.content[0].content.as_ref().unwrap();
9009        assert_eq!(
9010            cols[0].attrs.as_ref().unwrap()["localId"],
9011            "aabb112233cc",
9012            "flush-previous column should preserve localId"
9013        );
9014        assert_eq!(
9015            cols[1].attrs.as_ref().unwrap()["localId"],
9016            "ddeeff445566",
9017            "second column localId should be preserved"
9018        );
9019    }
9020
9021    #[test]
9022    fn layout_column_localid_flush_last() {
9023        // Layout with no closing fence → column never explicitly closed → flush-last path
9024        let md = "::::layout\n:::column{width=50 localId=aabb112233cc}\nOnly column.";
9025        let doc = markdown_to_adf(md).unwrap();
9026        let cols = doc.content[0].content.as_ref().unwrap();
9027        assert_eq!(
9028            cols[0].attrs.as_ref().unwrap()["localId"],
9029            "aabb112233cc",
9030            "flush-last column should preserve localId"
9031        );
9032    }
9033
9034    /// Issue #555: `layoutColumn` fractional `width` must round-trip byte-for-byte.
9035    #[test]
9036    fn issue_555_layout_column_fractional_width_roundtrip() {
9037        let adf_json = r#"{
9038            "version": 1,
9039            "type": "doc",
9040            "content": [{
9041                "type": "layoutSection",
9042                "content": [
9043                    {
9044                        "type": "layoutColumn",
9045                        "attrs": {"width": 66.66},
9046                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Wide"}]}]
9047                    },
9048                    {
9049                        "type": "layoutColumn",
9050                        "attrs": {"width": 33.34},
9051                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Narrow"}]}]
9052                    }
9053                ]
9054            }]
9055        }"#;
9056        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9057        let md = adf_to_markdown(&doc).unwrap();
9058        assert!(md.contains("width=66.66"), "fractional width in md: {md}");
9059        assert!(md.contains("width=33.34"), "fractional width in md: {md}");
9060        let rt = markdown_to_adf(&md).unwrap();
9061        let cols = rt.content[0].content.as_ref().unwrap();
9062        assert_eq!(cols[0].attrs.as_ref().unwrap()["width"], 66.66);
9063        assert_eq!(cols[1].attrs.as_ref().unwrap()["width"], 33.34);
9064    }
9065
9066    /// Issue #555: `layoutColumn` 5/6 widths (`83.33`) round-trip without precision loss.
9067    #[test]
9068    fn issue_555_layout_column_five_sixths_width_roundtrip() {
9069        let adf_json = r#"{
9070            "version": 1,
9071            "type": "doc",
9072            "content": [{
9073                "type": "layoutSection",
9074                "content": [
9075                    {
9076                        "type": "layoutColumn",
9077                        "attrs": {"width": 83.33},
9078                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Wide"}]}]
9079                    },
9080                    {
9081                        "type": "layoutColumn",
9082                        "attrs": {"width": 16.67},
9083                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Narrow"}]}]
9084                    }
9085                ]
9086            }]
9087        }"#;
9088        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9089        let md = adf_to_markdown(&doc).unwrap();
9090        let rt = markdown_to_adf(&md).unwrap();
9091        let cols = rt.content[0].content.as_ref().unwrap();
9092        assert_eq!(cols[0].attrs.as_ref().unwrap()["width"], 83.33);
9093        assert_eq!(cols[1].attrs.as_ref().unwrap()["width"], 16.67);
9094    }
9095
9096    /// Issue #555: `layoutColumn` integer widths must NOT be coerced to floats on round-trip.
9097    #[test]
9098    fn issue_555_layout_column_integer_width_preserved() {
9099        let adf_json = r#"{
9100            "version": 1,
9101            "type": "doc",
9102            "content": [{
9103                "type": "layoutSection",
9104                "content": [
9105                    {
9106                        "type": "layoutColumn",
9107                        "attrs": {"width": 50},
9108                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "A"}]}]
9109                    },
9110                    {
9111                        "type": "layoutColumn",
9112                        "attrs": {"width": 50},
9113                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "B"}]}]
9114                    }
9115                ]
9116            }]
9117        }"#;
9118        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9119        let md = adf_to_markdown(&doc).unwrap();
9120        assert!(
9121            md.contains("width=50") && !md.contains("width=50."),
9122            "integer width should render without decimal: {md}"
9123        );
9124        let rt = markdown_to_adf(&md).unwrap();
9125        let cols = rt.content[0].content.as_ref().unwrap();
9126        let w0 = &cols[0].attrs.as_ref().unwrap()["width"];
9127        assert!(
9128            w0.is_i64() || w0.is_u64(),
9129            "width should remain a JSON integer, got: {w0}"
9130        );
9131        assert_eq!(w0.as_i64(), Some(50));
9132    }
9133
9134    /// Issue #555: `mediaSingle` integer `width` must NOT be coerced to a float on round-trip.
9135    #[test]
9136    fn issue_555_media_single_integer_width_preserved() {
9137        let adf_json = r#"{
9138            "version": 1,
9139            "type": "doc",
9140            "content": [{
9141                "type": "mediaSingle",
9142                "attrs": {"layout": "center", "width": 800},
9143                "content": [
9144                    {"type": "media", "attrs": {"type": "external", "url": "https://example.com/image.png"}}
9145                ]
9146            }]
9147        }"#;
9148        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9149        let md = adf_to_markdown(&doc).unwrap();
9150        assert!(
9151            md.contains("width=800") && !md.contains("width=800."),
9152            "integer width should render without decimal: {md}"
9153        );
9154        let rt = markdown_to_adf(&md).unwrap();
9155        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9156        let w = &ms_attrs["width"];
9157        assert!(
9158            w.is_i64() || w.is_u64(),
9159            "mediaSingle width should remain JSON integer, got: {w}"
9160        );
9161        assert_eq!(w.as_i64(), Some(800));
9162    }
9163
9164    /// Issue #555 (follow-up): fractional `mediaSingle` width (e.g. `66.5`, a
9165    /// percentage-based size common in Jira layouts) must survive `from-adf`
9166    /// instead of being silently dropped.
9167    #[test]
9168    fn issue_555_media_single_fractional_width_preserved() {
9169        let adf_json = r#"{
9170            "version": 1,
9171            "type": "doc",
9172            "content": [{
9173                "type": "mediaSingle",
9174                "attrs": {"layout": "center", "width": 66.5},
9175                "content": [
9176                    {"type": "media", "attrs": {"type": "external", "url": "https://example.com/diagram.png"}}
9177                ]
9178            }]
9179        }"#;
9180        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9181        let md = adf_to_markdown(&doc).unwrap();
9182        assert!(
9183            md.contains("width=66.5"),
9184            "fractional width must appear in JFM: {md}"
9185        );
9186        let rt = markdown_to_adf(&md).unwrap();
9187        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9188        assert_eq!(ms_attrs["width"], 66.5);
9189    }
9190
9191    /// Issue #555: `mediaSingle` float `width` must not be dropped during ADF→JFM→ADF.
9192    #[test]
9193    fn issue_555_media_single_float_width_preserved() {
9194        let adf_json = r#"{
9195            "version": 1,
9196            "type": "doc",
9197            "content": [{
9198                "type": "mediaSingle",
9199                "attrs": {"layout": "center", "width": 800.0},
9200                "content": [
9201                    {"type": "media", "attrs": {"type": "external", "url": "https://example.com/image.png"}}
9202                ]
9203            }]
9204        }"#;
9205        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9206        let md = adf_to_markdown(&doc).unwrap();
9207        assert!(
9208            md.contains("width=800.0"),
9209            "float width should render with decimal: {md}"
9210        );
9211        let rt = markdown_to_adf(&md).unwrap();
9212        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9213        let w = &ms_attrs["width"];
9214        assert!(
9215            w.is_f64(),
9216            "mediaSingle float width should stay a JSON float, got: {w}"
9217        );
9218        assert_eq!(w.as_f64(), Some(800.0));
9219    }
9220
9221    /// Issue #555: `mediaSingle` with `layout=wide` and integer width must round-trip.
9222    #[test]
9223    fn issue_555_media_single_wide_layout_integer_width_roundtrip() {
9224        let adf_json = r#"{
9225            "version": 1,
9226            "type": "doc",
9227            "content": [{
9228                "type": "mediaSingle",
9229                "attrs": {"layout": "wide", "width": 1420},
9230                "content": [
9231                    {"type": "media", "attrs": {"type": "external", "url": "https://ex.com/x.png"}}
9232                ]
9233            }]
9234        }"#;
9235        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9236        let md = adf_to_markdown(&doc).unwrap();
9237        let rt = markdown_to_adf(&md).unwrap();
9238        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9239        assert_eq!(ms_attrs["layout"], "wide");
9240        let w = &ms_attrs["width"];
9241        assert!(
9242            w.is_i64() || w.is_u64(),
9243            "mediaSingle width should remain JSON integer, got: {w}"
9244        );
9245        assert_eq!(w.as_i64(), Some(1420));
9246    }
9247
9248    /// Issue #555: Confluence file-attachment `mediaSingle` with integer `mediaWidth`
9249    /// must round-trip without float coercion.
9250    #[test]
9251    fn issue_555_file_media_single_integer_width_preserved() {
9252        let adf_json = r#"{
9253            "version": 1,
9254            "type": "doc",
9255            "content": [{
9256                "type": "mediaSingle",
9257                "attrs": {"layout": "wide", "width": 1420},
9258                "content": [
9259                    {"type": "media", "attrs": {"id": "abc-123", "type": "file", "collection": "col-1", "width": 1200, "height": 800}}
9260                ]
9261            }]
9262        }"#;
9263        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9264        let md = adf_to_markdown(&doc).unwrap();
9265        let rt = markdown_to_adf(&md).unwrap();
9266        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9267        let ms_w = &ms_attrs["width"];
9268        assert!(ms_w.is_i64() || ms_w.is_u64(), "ms width: {ms_w}");
9269        assert_eq!(ms_w.as_i64(), Some(1420));
9270        let media = &rt.content[0].content.as_ref().unwrap()[0];
9271        let media_attrs = media.attrs.as_ref().unwrap();
9272        let mw = &media_attrs["width"];
9273        assert!(mw.is_i64() || mw.is_u64(), "media width: {mw}");
9274        assert_eq!(mw.as_i64(), Some(1200));
9275        let mh = &media_attrs["height"];
9276        assert!(mh.is_i64() || mh.is_u64(), "media height: {mh}");
9277        assert_eq!(mh.as_i64(), Some(800));
9278    }
9279
9280    /// Issue #555: `fmt_numeric_attr` preserves the original integer/float JSON type.
9281    #[test]
9282    fn issue_555_fmt_numeric_attr_preserves_type() {
9283        assert_eq!(
9284            fmt_numeric_attr(&serde_json::json!(50)).as_deref(),
9285            Some("50")
9286        );
9287        assert_eq!(
9288            fmt_numeric_attr(&serde_json::json!(50.0)).as_deref(),
9289            Some("50.0")
9290        );
9291        assert_eq!(
9292            fmt_numeric_attr(&serde_json::json!(66.66)).as_deref(),
9293            Some("66.66")
9294        );
9295        assert_eq!(
9296            fmt_numeric_attr(&serde_json::json!(-5)).as_deref(),
9297            Some("-5")
9298        );
9299        assert_eq!(fmt_numeric_attr(&serde_json::json!("not a number")), None);
9300        // u64 values above i64::MAX exercise the u64-only branch.
9301        let big = serde_json::Value::Number(serde_json::Number::from(u64::MAX));
9302        assert_eq!(
9303            fmt_numeric_attr(&big).as_deref(),
9304            Some("18446744073709551615")
9305        );
9306        // Null is not a number.
9307        assert_eq!(fmt_numeric_attr(&serde_json::Value::Null), None);
9308    }
9309
9310    /// Issue #555: `parse_numeric_attr` distinguishes integer vs float strings.
9311    #[test]
9312    fn issue_555_parse_numeric_attr_detects_type() {
9313        let v = parse_numeric_attr("800").unwrap();
9314        assert!(v.is_i64() || v.is_u64(), "'800' should parse as integer");
9315        assert_eq!(v.as_i64(), Some(800));
9316
9317        let v = parse_numeric_attr("800.0").unwrap();
9318        assert!(v.is_f64(), "'800.0' should parse as float");
9319        assert_eq!(v.as_f64(), Some(800.0));
9320
9321        let v = parse_numeric_attr("66.66").unwrap();
9322        assert!(v.is_f64());
9323        assert_eq!(v.as_f64(), Some(66.66));
9324
9325        // Scientific notation is treated as float (matches JSON semantics).
9326        let v = parse_numeric_attr("1e2").unwrap();
9327        assert!(v.is_f64());
9328        assert_eq!(v.as_f64(), Some(100.0));
9329        let v = parse_numeric_attr("1E2").unwrap();
9330        assert!(v.is_f64());
9331        assert_eq!(v.as_f64(), Some(100.0));
9332
9333        // Negative integer.
9334        let v = parse_numeric_attr("-42").unwrap();
9335        assert!(v.is_i64());
9336        assert_eq!(v.as_i64(), Some(-42));
9337
9338        assert!(parse_numeric_attr("not-a-number").is_none());
9339        assert!(parse_numeric_attr("").is_none());
9340        assert!(parse_numeric_attr("1.2.3").is_none());
9341    }
9342
9343    /// Issue #555: fractional `mediaSingle` width with non-default `layout=wide`
9344    /// must preserve both the layout and the fractional width through round-trip.
9345    #[test]
9346    fn issue_555_media_single_wide_layout_fractional_width_roundtrip() {
9347        let adf_json = r#"{
9348            "version": 1,
9349            "type": "doc",
9350            "content": [{
9351                "type": "mediaSingle",
9352                "attrs": {"layout": "wide", "width": 83.33},
9353                "content": [
9354                    {"type": "media", "attrs": {"type": "external", "url": "https://ex.com/x.png"}}
9355                ]
9356            }]
9357        }"#;
9358        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9359        let md = adf_to_markdown(&doc).unwrap();
9360        assert!(md.contains("layout=wide"), "layout must appear in md: {md}");
9361        assert!(md.contains("width=83.33"), "width must appear in md: {md}");
9362        let rt = markdown_to_adf(&md).unwrap();
9363        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9364        assert_eq!(ms_attrs["layout"], "wide");
9365        assert_eq!(ms_attrs["width"], 83.33);
9366    }
9367
9368    /// Issue #555: fractional `mediaWidth` on a Confluence file-attachment
9369    /// `mediaSingle` must round-trip (exercises the file-branch `mediaWidth`
9370    /// render path, which previously used `as_u64` and silently dropped floats).
9371    #[test]
9372    fn issue_555_file_media_single_fractional_media_width_preserved() {
9373        let adf_json = r#"{
9374            "version": 1,
9375            "type": "doc",
9376            "content": [{
9377                "type": "mediaSingle",
9378                "attrs": {"layout": "wide", "width": 66.5},
9379                "content": [
9380                    {"type": "media", "attrs": {"id": "abc", "type": "file", "collection": "c"}}
9381                ]
9382            }]
9383        }"#;
9384        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9385        let md = adf_to_markdown(&doc).unwrap();
9386        assert!(md.contains("mediaWidth=66.5"), "mediaWidth in md: {md}");
9387        let rt = markdown_to_adf(&md).unwrap();
9388        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9389        assert_eq!(ms_attrs["width"], 66.5);
9390    }
9391
9392    /// Issue #555: fractional inner `media` width/height on a file attachment
9393    /// must round-trip (exercises the file-branch inner `width`/`height` render
9394    /// path, which previously used `as_u64` and silently dropped floats).
9395    #[test]
9396    fn issue_555_file_media_fractional_inner_dimensions_preserved() {
9397        let adf_json = r#"{
9398            "version": 1,
9399            "type": "doc",
9400            "content": [{
9401                "type": "mediaSingle",
9402                "attrs": {"layout": "center"},
9403                "content": [
9404                    {"type": "media", "attrs": {"id": "abc", "type": "file", "collection": "c", "width": 1200.5, "height": 800.25}}
9405                ]
9406            }]
9407        }"#;
9408        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9409        let md = adf_to_markdown(&doc).unwrap();
9410        assert!(md.contains("width=1200.5"), "width in md: {md}");
9411        assert!(md.contains("height=800.25"), "height in md: {md}");
9412        let rt = markdown_to_adf(&md).unwrap();
9413        let media = &rt.content[0].content.as_ref().unwrap()[0];
9414        let attrs = media.attrs.as_ref().unwrap();
9415        assert_eq!(attrs["width"], 1200.5);
9416        assert_eq!(attrs["height"], 800.25);
9417    }
9418
9419    #[test]
9420    fn decisions_list() {
9421        let md = ":::decisions\n- <> Use PostgreSQL\n- <> REST API\n:::";
9422        let doc = markdown_to_adf(md).unwrap();
9423        assert_eq!(doc.content[0].node_type, "decisionList");
9424        let items = doc.content[0].content.as_ref().unwrap();
9425        assert_eq!(items.len(), 2);
9426        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DECIDED");
9427    }
9428
9429    #[test]
9430    fn adf_decisions_to_markdown() {
9431        let doc = AdfDocument {
9432            version: 1,
9433            doc_type: "doc".to_string(),
9434            content: vec![AdfNode::decision_list(vec![AdfNode::decision_item(
9435                "DECIDED",
9436                vec![AdfNode::paragraph(vec![AdfNode::text("Use PostgreSQL")])],
9437            )])],
9438        };
9439        let md = adf_to_markdown(&doc).unwrap();
9440        assert!(md.contains(":::decisions"));
9441        assert!(md.contains("- <> Use PostgreSQL"));
9442    }
9443
9444    #[test]
9445    fn bodied_extension_container() {
9446        let md = ":::extension{type=com.forge key=my-macro}\nContent.\n:::";
9447        let doc = markdown_to_adf(md).unwrap();
9448        assert_eq!(doc.content[0].node_type, "bodiedExtension");
9449        assert_eq!(
9450            doc.content[0].attrs.as_ref().unwrap()["extensionType"],
9451            "com.forge"
9452        );
9453    }
9454
9455    #[test]
9456    fn adf_bodied_extension_to_markdown() {
9457        let doc = AdfDocument {
9458            version: 1,
9459            doc_type: "doc".to_string(),
9460            content: vec![AdfNode::bodied_extension(
9461                "com.forge",
9462                "my-macro",
9463                vec![AdfNode::paragraph(vec![AdfNode::text("Content.")])],
9464            )],
9465        };
9466        let md = adf_to_markdown(&doc).unwrap();
9467        assert!(md.contains(":::extension{type=com.forge key=my-macro}"));
9468        assert!(md.contains("Content."));
9469    }
9470
9471    // ── Leaf block directive tests (Tier 3) ──────────────────────────
9472
9473    #[test]
9474    fn leaf_block_card() {
9475        let doc = markdown_to_adf("::card[https://example.com/browse/PROJ-123]").unwrap();
9476        assert_eq!(doc.content[0].node_type, "blockCard");
9477        assert_eq!(
9478            doc.content[0].attrs.as_ref().unwrap()["url"],
9479            "https://example.com/browse/PROJ-123"
9480        );
9481    }
9482
9483    #[test]
9484    fn adf_block_card_to_markdown() {
9485        let doc = AdfDocument {
9486            version: 1,
9487            doc_type: "doc".to_string(),
9488            content: vec![AdfNode::block_card("https://example.com/browse/PROJ-123")],
9489        };
9490        let md = adf_to_markdown(&doc).unwrap();
9491        assert!(md.contains("::card[https://example.com/browse/PROJ-123]"));
9492    }
9493
9494    #[test]
9495    fn round_trip_block_card() {
9496        let md = "::card[https://example.com/browse/PROJ-123]\n";
9497        let doc = markdown_to_adf(md).unwrap();
9498        let result = adf_to_markdown(&doc).unwrap();
9499        assert!(result.contains("::card[https://example.com/browse/PROJ-123]"));
9500    }
9501
9502    #[test]
9503    fn leaf_embed_card() {
9504        let doc =
9505            markdown_to_adf("::embed[https://figma.com/file/abc]{layout=wide width=80}").unwrap();
9506        assert_eq!(doc.content[0].node_type, "embedCard");
9507        let attrs = doc.content[0].attrs.as_ref().unwrap();
9508        assert_eq!(attrs["url"], "https://figma.com/file/abc");
9509        assert_eq!(attrs["layout"], "wide");
9510        assert_eq!(attrs["width"], 80.0);
9511    }
9512
9513    #[test]
9514    fn leaf_embed_card_with_original_height() {
9515        let doc = markdown_to_adf(
9516            "::embed[https://example.com]{layout=center originalHeight=732 width=100}",
9517        )
9518        .unwrap();
9519        assert_eq!(doc.content[0].node_type, "embedCard");
9520        let attrs = doc.content[0].attrs.as_ref().unwrap();
9521        assert_eq!(attrs["url"], "https://example.com");
9522        assert_eq!(attrs["layout"], "center");
9523        assert_eq!(attrs["originalHeight"], 732.0);
9524        assert_eq!(attrs["width"], 100.0);
9525    }
9526
9527    #[test]
9528    fn adf_embed_card_to_markdown() {
9529        let doc = AdfDocument {
9530            version: 1,
9531            doc_type: "doc".to_string(),
9532            content: vec![AdfNode::embed_card(
9533                "https://figma.com/file/abc",
9534                Some("wide"),
9535                None,
9536                Some(80.0),
9537            )],
9538        };
9539        let md = adf_to_markdown(&doc).unwrap();
9540        assert!(md.contains("::embed[https://figma.com/file/abc]{layout=wide width=80}"));
9541    }
9542
9543    #[test]
9544    fn adf_embed_card_original_height_to_markdown() {
9545        let doc = AdfDocument {
9546            version: 1,
9547            doc_type: "doc".to_string(),
9548            content: vec![AdfNode::embed_card(
9549                "https://example.com",
9550                Some("center"),
9551                Some(732.0),
9552                Some(100.0),
9553            )],
9554        };
9555        let md = adf_to_markdown(&doc).unwrap();
9556        assert!(
9557            md.contains("::embed[https://example.com]{layout=center originalHeight=732 width=100}"),
9558            "expected originalHeight and width in md: {md}"
9559        );
9560    }
9561
9562    #[test]
9563    fn embed_card_roundtrip_with_all_attrs() {
9564        let adf_json = r#"{"version":1,"type":"doc","content":[{
9565            "type":"embedCard",
9566            "attrs":{"layout":"center","originalHeight":732.0,"url":"https://example.com","width":100.0}
9567        }]}"#;
9568        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9569        let md = adf_to_markdown(&doc).unwrap();
9570        assert!(
9571            md.contains("originalHeight=732"),
9572            "originalHeight missing from md: {md}"
9573        );
9574        assert!(md.contains("width=100"), "width missing from md: {md}");
9575        let rt = markdown_to_adf(&md).unwrap();
9576        let attrs = rt.content[0].attrs.as_ref().unwrap();
9577        assert_eq!(attrs["originalHeight"], 732.0);
9578        assert_eq!(attrs["width"], 100.0);
9579        assert_eq!(attrs["layout"], "center");
9580        assert_eq!(attrs["url"], "https://example.com");
9581    }
9582
9583    #[test]
9584    fn embed_card_fractional_dimensions() {
9585        let doc = AdfDocument {
9586            version: 1,
9587            doc_type: "doc".to_string(),
9588            content: vec![AdfNode::embed_card(
9589                "https://example.com",
9590                Some("center"),
9591                Some(732.5),
9592                Some(99.9),
9593            )],
9594        };
9595        let md = adf_to_markdown(&doc).unwrap();
9596        assert!(
9597            md.contains("originalHeight=732.5"),
9598            "fractional originalHeight missing: {md}"
9599        );
9600        assert!(md.contains("width=99.9"), "fractional width missing: {md}");
9601        let rt = markdown_to_adf(&md).unwrap();
9602        let attrs = rt.content[0].attrs.as_ref().unwrap();
9603        assert_eq!(attrs["originalHeight"], 732.5);
9604        assert_eq!(attrs["width"], 99.9);
9605    }
9606
9607    #[test]
9608    fn embed_card_integer_width_in_json() {
9609        // JSON integer (not float) should also be extracted via as_f64()
9610        let adf_json = r#"{"version":1,"type":"doc","content":[{
9611            "type":"embedCard",
9612            "attrs":{"url":"https://example.com","width":100}
9613        }]}"#;
9614        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9615        let md = adf_to_markdown(&doc).unwrap();
9616        assert!(
9617            md.contains("width=100"),
9618            "integer width missing from md: {md}"
9619        );
9620        let rt = markdown_to_adf(&md).unwrap();
9621        assert_eq!(rt.content[0].attrs.as_ref().unwrap()["width"], 100.0);
9622    }
9623
9624    #[test]
9625    fn embed_card_only_original_height() {
9626        // originalHeight without width
9627        let adf_json = r#"{"version":1,"type":"doc","content":[{
9628            "type":"embedCard",
9629            "attrs":{"url":"https://example.com","originalHeight":500.0}
9630        }]}"#;
9631        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9632        let md = adf_to_markdown(&doc).unwrap();
9633        assert!(
9634            md.contains("originalHeight=500"),
9635            "originalHeight missing: {md}"
9636        );
9637        assert!(!md.contains("width="), "width should not appear: {md}");
9638        let rt = markdown_to_adf(&md).unwrap();
9639        let attrs = rt.content[0].attrs.as_ref().unwrap();
9640        assert_eq!(attrs["originalHeight"], 500.0);
9641        assert!(attrs.get("width").is_none());
9642    }
9643
9644    #[test]
9645    fn leaf_void_extension() {
9646        let doc = markdown_to_adf("::extension{type=com.atlassian.macro key=jira-chart}").unwrap();
9647        assert_eq!(doc.content[0].node_type, "extension");
9648        assert_eq!(
9649            doc.content[0].attrs.as_ref().unwrap()["extensionType"],
9650            "com.atlassian.macro"
9651        );
9652        assert_eq!(
9653            doc.content[0].attrs.as_ref().unwrap()["extensionKey"],
9654            "jira-chart"
9655        );
9656    }
9657
9658    #[test]
9659    fn adf_void_extension_to_markdown() {
9660        let doc = AdfDocument {
9661            version: 1,
9662            doc_type: "doc".to_string(),
9663            content: vec![AdfNode::extension(
9664                "com.atlassian.macro",
9665                "jira-chart",
9666                None,
9667            )],
9668        };
9669        let md = adf_to_markdown(&doc).unwrap();
9670        assert!(md.contains("::extension{type=com.atlassian.macro key=jira-chart}"));
9671    }
9672
9673    // ── Bare URL autolink tests ──────────────────────────────────────
9674
9675    #[test]
9676    fn bare_url_autolink() {
9677        let doc = markdown_to_adf("Visit https://example.com today").unwrap();
9678        let content = doc.content[0].content.as_ref().unwrap();
9679        assert_eq!(content[0].text.as_deref(), Some("Visit "));
9680        assert_eq!(content[1].node_type, "inlineCard");
9681        assert_eq!(
9682            content[1].attrs.as_ref().unwrap()["url"],
9683            "https://example.com"
9684        );
9685        assert_eq!(content[2].text.as_deref(), Some(" today"));
9686    }
9687
9688    #[test]
9689    fn bare_url_strips_trailing_punctuation() {
9690        let doc = markdown_to_adf("See https://example.com.").unwrap();
9691        let content = doc.content[0].content.as_ref().unwrap();
9692        assert_eq!(
9693            content[1].attrs.as_ref().unwrap()["url"],
9694            "https://example.com"
9695        );
9696    }
9697
9698    #[test]
9699    fn bare_url_round_trip() {
9700        let doc = markdown_to_adf("Visit https://example.com/path today").unwrap();
9701        let md = adf_to_markdown(&doc).unwrap();
9702        assert!(md.contains(":card[https://example.com/path]"));
9703    }
9704
9705    // ── Issue #475: plain-text URL must not become inlineCard ─────────
9706
9707    #[test]
9708    fn plain_text_url_round_trips_as_text() {
9709        // A text node whose content is a bare URL (no link mark) must
9710        // survive ADF→JFM→ADF as a text node, not an inlineCard.
9711        let adf_json = r#"{
9712            "version": 1,
9713            "type": "doc",
9714            "content": [{
9715                "type": "paragraph",
9716                "content": [
9717                    {"type": "text", "text": "https://example.com/some/path/to/resource"}
9718                ]
9719            }]
9720        }"#;
9721        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9722        let jfm = adf_to_markdown(&adf).unwrap();
9723        let roundtripped = markdown_to_adf(&jfm).unwrap();
9724        let content = roundtripped.content[0].content.as_ref().unwrap();
9725        assert_eq!(content.len(), 1, "should be a single node");
9726        assert_eq!(content[0].node_type, "text");
9727        assert_eq!(
9728            content[0].text.as_deref(),
9729            Some("https://example.com/some/path/to/resource")
9730        );
9731    }
9732
9733    #[test]
9734    fn url_text_with_link_mark_round_trips_as_text_node() {
9735        // Issue #523: A text node whose content is a URL with a link mark
9736        // (href differs by trailing slash) must round-trip as text+link,
9737        // not become an inlineCard.
9738        let adf_json = r#"{
9739            "version": 1,
9740            "type": "doc",
9741            "content": [{
9742                "type": "paragraph",
9743                "content": [{
9744                    "type": "text",
9745                    "text": "https://octopz.example.com",
9746                    "marks": [{"type": "link", "attrs": {"href": "https://octopz.example.com/"}}]
9747                }]
9748            }]
9749        }"#;
9750        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9751        let jfm = adf_to_markdown(&adf).unwrap();
9752        let roundtripped = markdown_to_adf(&jfm).unwrap();
9753        let content = roundtripped.content[0].content.as_ref().unwrap();
9754        assert_eq!(content.len(), 1, "should be a single node");
9755        assert_eq!(content[0].node_type, "text", "must be text, not inlineCard");
9756        assert_eq!(
9757            content[0].text.as_deref(),
9758            Some("https://octopz.example.com")
9759        );
9760        let mark = &content[0].marks.as_ref().unwrap()[0];
9761        assert_eq!(mark.mark_type, "link");
9762        assert_eq!(
9763            mark.attrs.as_ref().unwrap()["href"],
9764            "https://octopz.example.com/"
9765        );
9766    }
9767
9768    #[test]
9769    fn url_text_with_exact_link_mark_round_trips() {
9770        // Variant: text and href are identical (no trailing slash difference).
9771        let adf_json = r#"{
9772            "version": 1,
9773            "type": "doc",
9774            "content": [{
9775                "type": "paragraph",
9776                "content": [{
9777                    "type": "text",
9778                    "text": "https://example.com/path",
9779                    "marks": [{"type": "link", "attrs": {"href": "https://example.com/path"}}]
9780                }]
9781            }]
9782        }"#;
9783        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9784        let jfm = adf_to_markdown(&adf).unwrap();
9785        let roundtripped = markdown_to_adf(&jfm).unwrap();
9786        let content = roundtripped.content[0].content.as_ref().unwrap();
9787        assert_eq!(content.len(), 1, "should be a single node");
9788        assert_eq!(content[0].node_type, "text");
9789        assert_eq!(content[0].text.as_deref(), Some("https://example.com/path"));
9790        let mark = &content[0].marks.as_ref().unwrap()[0];
9791        assert_eq!(mark.mark_type, "link");
9792    }
9793
9794    #[test]
9795    fn plain_text_url_amid_text_round_trips() {
9796        // URL embedded in surrounding text, without link mark.
9797        let adf_json = r#"{
9798            "version": 1,
9799            "type": "doc",
9800            "content": [{
9801                "type": "paragraph",
9802                "content": [
9803                    {"type": "text", "text": "see https://example.com for info"}
9804                ]
9805            }]
9806        }"#;
9807        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9808        let jfm = adf_to_markdown(&adf).unwrap();
9809        let roundtripped = markdown_to_adf(&jfm).unwrap();
9810        let content = roundtripped.content[0].content.as_ref().unwrap();
9811        assert_eq!(content.len(), 1);
9812        assert_eq!(content[0].node_type, "text");
9813        assert_eq!(
9814            content[0].text.as_deref(),
9815            Some("see https://example.com for info")
9816        );
9817    }
9818
9819    #[test]
9820    fn plain_text_multiple_urls_round_trips() {
9821        let adf_json = r#"{
9822            "version": 1,
9823            "type": "doc",
9824            "content": [{
9825                "type": "paragraph",
9826                "content": [
9827                    {"type": "text", "text": "http://a.com and https://b.com"}
9828                ]
9829            }]
9830        }"#;
9831        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9832        let jfm = adf_to_markdown(&adf).unwrap();
9833        let roundtripped = markdown_to_adf(&jfm).unwrap();
9834        let content = roundtripped.content[0].content.as_ref().unwrap();
9835        assert_eq!(content.len(), 1);
9836        assert_eq!(content[0].node_type, "text");
9837        assert_eq!(
9838            content[0].text.as_deref(),
9839            Some("http://a.com and https://b.com")
9840        );
9841    }
9842
9843    #[test]
9844    fn plain_text_http_prefix_no_url_unchanged() {
9845        // "http" without "://" should not be escaped or altered.
9846        let adf_json = r#"{
9847            "version": 1,
9848            "type": "doc",
9849            "content": [{
9850                "type": "paragraph",
9851                "content": [
9852                    {"type": "text", "text": "the http header is important"}
9853                ]
9854            }]
9855        }"#;
9856        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9857        let jfm = adf_to_markdown(&adf).unwrap();
9858        let roundtripped = markdown_to_adf(&jfm).unwrap();
9859        let content = roundtripped.content[0].content.as_ref().unwrap();
9860        assert_eq!(
9861            content[0].text.as_deref(),
9862            Some("the http header is important")
9863        );
9864    }
9865
9866    #[test]
9867    fn linked_url_text_round_trips() {
9868        // A text node that is exactly a URL with a link mark pointing to the
9869        // same URL must round-trip as a single text node with a link mark
9870        // (no inlineCard, no lost/split content).
9871        let adf_json = r#"{
9872            "version": 1,
9873            "type": "doc",
9874            "content": [{
9875                "type": "paragraph",
9876                "content": [{
9877                    "type": "text",
9878                    "text": "https://example.com",
9879                    "marks": [{"type": "link", "attrs": {"href": "https://example.com"}}]
9880                }]
9881            }]
9882        }"#;
9883        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9884        let jfm = adf_to_markdown(&adf).unwrap();
9885        let roundtripped = markdown_to_adf(&jfm).unwrap();
9886        let content = roundtripped.content[0].content.as_ref().unwrap();
9887        assert_eq!(content.len(), 1);
9888        assert_eq!(content[0].node_type, "text");
9889        assert_eq!(content[0].text.as_deref(), Some("https://example.com"));
9890        let mark = &content[0].marks.as_ref().unwrap()[0];
9891        assert_eq!(mark.mark_type, "link");
9892        assert_eq!(mark.attrs.as_ref().unwrap()["href"], "https://example.com");
9893    }
9894
9895    // ── Issue #493: bracket-link ambiguity ─────────────────────────────
9896
9897    #[test]
9898    fn escape_link_brackets_unit() {
9899        assert_eq!(escape_link_brackets("hello"), "hello");
9900        assert_eq!(escape_link_brackets("["), "\\[");
9901        assert_eq!(escape_link_brackets("]"), "\\]");
9902        assert_eq!(escape_link_brackets("[PROJ-456]"), "\\[PROJ-456\\]");
9903        assert_eq!(escape_link_brackets("a[b]c"), "a\\[b\\]c");
9904    }
9905
9906    #[test]
9907    fn bracket_text_with_link_mark_escapes_brackets() {
9908        // A text node whose content is "[" with a link mark should escape
9909        // the bracket so it does not create ambiguous markdown link syntax.
9910        let doc = AdfDocument {
9911            version: 1,
9912            doc_type: "doc".to_string(),
9913            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
9914                "[",
9915                vec![AdfMark::link("https://example.com")],
9916            )])],
9917        };
9918        let md = adf_to_markdown(&doc).unwrap();
9919        assert_eq!(md.trim(), "[\\[](https://example.com)");
9920    }
9921
9922    #[test]
9923    fn bracket_text_with_link_mark_round_trips() {
9924        // Issue #493 reproducer: adjacent text nodes sharing a link mark
9925        // where the first node's content is "[".
9926        let adf_json = r#"{
9927            "type": "doc",
9928            "version": 1,
9929            "content": [{
9930                "type": "paragraph",
9931                "content": [
9932                    {
9933                        "type": "text",
9934                        "text": "[",
9935                        "marks": [{"type": "link", "attrs": {"href": "https://example.com/ticket/123"}}]
9936                    },
9937                    {
9938                        "type": "text",
9939                        "text": "PROJ-456] Fix the auth bug",
9940                        "marks": [
9941                            {"type": "underline"},
9942                            {"type": "link", "attrs": {"href": "https://example.com/ticket/123"}}
9943                        ]
9944                    }
9945                ]
9946            }]
9947        }"#;
9948        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9949        let jfm = adf_to_markdown(&adf).unwrap();
9950
9951        // The markdown should contain escaped brackets inside the link
9952        assert!(jfm.contains("\\["), "opening bracket should be escaped");
9953
9954        // Round-trip: both text nodes must survive with link marks
9955        let rt = markdown_to_adf(&jfm).unwrap();
9956        let content = rt.content[0].content.as_ref().unwrap();
9957
9958        // All text nodes that were part of the link must still carry a link mark
9959        let link_nodes: Vec<_> = content
9960            .iter()
9961            .filter(|n| {
9962                n.marks
9963                    .as_ref()
9964                    .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "link"))
9965            })
9966            .collect();
9967        assert!(
9968            !link_nodes.is_empty(),
9969            "link mark must be preserved on round-trip"
9970        );
9971
9972        // The combined text across all nodes should contain the original content
9973        let all_text: String = content.iter().filter_map(|n| n.text.as_deref()).collect();
9974        assert!(
9975            all_text.contains('['),
9976            "literal '[' must survive round-trip"
9977        );
9978        assert!(
9979            all_text.contains("PROJ-456]"),
9980            "continuation text must survive round-trip"
9981        );
9982    }
9983
9984    #[test]
9985    fn closing_bracket_in_link_text_round_trips() {
9986        // A text node containing "]" inside a link should be escaped and
9987        // survive round-trip without breaking the link syntax.
9988        let doc = AdfDocument {
9989            version: 1,
9990            doc_type: "doc".to_string(),
9991            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
9992                "item]",
9993                vec![AdfMark::link("https://example.com")],
9994            )])],
9995        };
9996        let md = adf_to_markdown(&doc).unwrap();
9997        assert_eq!(md.trim(), "[item\\]](https://example.com)");
9998
9999        let rt = markdown_to_adf(&md).unwrap();
10000        let content = rt.content[0].content.as_ref().unwrap();
10001        assert_eq!(content[0].text.as_deref(), Some("item]"));
10002        assert!(content[0]
10003            .marks
10004            .as_ref()
10005            .unwrap()
10006            .iter()
10007            .any(|m| m.mark_type == "link"));
10008    }
10009
10010    #[test]
10011    fn brackets_in_link_text_round_trip() {
10012        // Text containing both [ and ] inside a link should round-trip.
10013        let doc = AdfDocument {
10014            version: 1,
10015            doc_type: "doc".to_string(),
10016            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10017                "[PROJ-123]",
10018                vec![AdfMark::link("https://example.com")],
10019            )])],
10020        };
10021        let md = adf_to_markdown(&doc).unwrap();
10022        assert_eq!(md.trim(), "[\\[PROJ-123\\]](https://example.com)");
10023
10024        let rt = markdown_to_adf(&md).unwrap();
10025        let content = rt.content[0].content.as_ref().unwrap();
10026        assert_eq!(content[0].text.as_deref(), Some("[PROJ-123]"));
10027        assert!(content[0]
10028            .marks
10029            .as_ref()
10030            .unwrap()
10031            .iter()
10032            .any(|m| m.mark_type == "link"));
10033    }
10034
10035    #[test]
10036    fn plain_text_brackets_not_escaped() {
10037        // Brackets in plain text (no link mark) must NOT be escaped.
10038        let doc = AdfDocument {
10039            version: 1,
10040            doc_type: "doc".to_string(),
10041            content: vec![AdfNode::paragraph(vec![AdfNode::text(
10042                "see [PROJ-123] for details",
10043            )])],
10044        };
10045        let md = adf_to_markdown(&doc).unwrap();
10046        assert_eq!(md.trim(), "see [PROJ-123] for details");
10047    }
10048
10049    #[test]
10050    fn link_with_no_brackets_unchanged() {
10051        // A normal link with no brackets in the text should be unaffected.
10052        let doc = AdfDocument {
10053            version: 1,
10054            doc_type: "doc".to_string(),
10055            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10056                "click here",
10057                vec![AdfMark::link("https://example.com")],
10058            )])],
10059        };
10060        let md = adf_to_markdown(&doc).unwrap();
10061        assert_eq!(md.trim(), "[click here](https://example.com)");
10062    }
10063
10064    // ── Issue #551: URL brackets in link-marked text round-trip ────────
10065
10066    #[test]
10067    fn url_with_brackets_as_link_text_round_trips() {
10068        // Issue #551: a text node whose content is a URL containing square
10069        // brackets and which carries a link mark must round-trip verbatim.
10070        // Previously the URL-as-link-text fast path preserved `\[` and `\]`
10071        // escapes in the emitted text, corrupting the text content.
10072        let href = "https://example.com/dashboard?filter[0]=active&filter[1]=pending";
10073        let doc = AdfDocument {
10074            version: 1,
10075            doc_type: "doc".to_string(),
10076            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10077                href,
10078                vec![AdfMark::link(href)],
10079            )])],
10080        };
10081        let md = adf_to_markdown(&doc).unwrap();
10082        let rt = markdown_to_adf(&md).unwrap();
10083        let content = rt.content[0].content.as_ref().unwrap();
10084        assert_eq!(content.len(), 1);
10085        assert_eq!(content[0].node_type, "text");
10086        assert_eq!(content[0].text.as_deref(), Some(href));
10087        let mark = &content[0].marks.as_ref().unwrap()[0];
10088        assert_eq!(mark.mark_type, "link");
10089        assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10090    }
10091
10092    #[test]
10093    fn url_with_brackets_embedded_in_link_text_round_trips() {
10094        // Issue #551 updated reproducer: a link-marked text node containing
10095        // both prose and an embedded URL with brackets must round-trip
10096        // without the embedded URL being detected as a bare-URL inlineCard
10097        // or the brackets terminating the link syntax early.  This mirrors
10098        // the comment reproducer which uses an ellipsis character between
10099        // the brackets and a distinct href value.
10100        let href = "https://example.com/logs?query=service%20environment%20data&from=100&to=200";
10101        let text =
10102            "See the logs: https://example.com/logs?query=service[\u{2026}]data&from=100&to=200";
10103        let doc = AdfDocument {
10104            version: 1,
10105            doc_type: "doc".to_string(),
10106            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10107                text,
10108                vec![AdfMark::link(href)],
10109            )])],
10110        };
10111        let md = adf_to_markdown(&doc).unwrap();
10112        let rt = markdown_to_adf(&md).unwrap();
10113        let content = rt.content[0].content.as_ref().unwrap();
10114        assert_eq!(content.len(), 1, "content split unexpectedly: {content:?}");
10115        assert_eq!(content[0].node_type, "text");
10116        assert_eq!(content[0].text.as_deref(), Some(text));
10117        let mark = &content[0].marks.as_ref().unwrap()[0];
10118        assert_eq!(mark.mark_type, "link");
10119        assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10120    }
10121
10122    #[test]
10123    fn url_with_brackets_plain_text_round_trips() {
10124        // Issue #551 original reproducer: plain text with an embedded URL
10125        // that contains square brackets must round-trip verbatim.
10126        let text =
10127            "See the dashboard: https://example.com/dashboard?filter[0]=active&filter[1]=pending";
10128        let doc = AdfDocument {
10129            version: 1,
10130            doc_type: "doc".to_string(),
10131            content: vec![AdfNode::paragraph(vec![AdfNode::text(text)])],
10132        };
10133        let md = adf_to_markdown(&doc).unwrap();
10134        let rt = markdown_to_adf(&md).unwrap();
10135        let content = rt.content[0].content.as_ref().unwrap();
10136        assert_eq!(content.len(), 1);
10137        assert_eq!(content[0].node_type, "text");
10138        assert_eq!(content[0].text.as_deref(), Some(text));
10139        assert!(content[0].marks.is_none());
10140    }
10141
10142    #[test]
10143    fn url_with_link_mark_embedded_no_brackets_round_trips() {
10144        // Regression guard: embedding a bare URL inside link-marked text
10145        // (no brackets) must not create an inlineCard on round-trip.
10146        let href = "https://example.com/";
10147        let text = "See https://example.com/ for more";
10148        let doc = AdfDocument {
10149            version: 1,
10150            doc_type: "doc".to_string(),
10151            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10152                text,
10153                vec![AdfMark::link(href)],
10154            )])],
10155        };
10156        let md = adf_to_markdown(&doc).unwrap();
10157        let rt = markdown_to_adf(&md).unwrap();
10158        let content = rt.content[0].content.as_ref().unwrap();
10159        assert_eq!(content.len(), 1);
10160        assert_eq!(content[0].node_type, "text");
10161        assert_eq!(content[0].text.as_deref(), Some(text));
10162        let mark = &content[0].marks.as_ref().unwrap()[0];
10163        assert_eq!(mark.mark_type, "link");
10164        assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10165    }
10166
10167    #[test]
10168    fn nested_brackets_in_link_text_round_trip() {
10169        // Regression guard: nested brackets in link-marked text must
10170        // round-trip without corrupting the content.
10171        let href = "https://x.com";
10172        let text = "foo [a[b]c] bar";
10173        let doc = AdfDocument {
10174            version: 1,
10175            doc_type: "doc".to_string(),
10176            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10177                text,
10178                vec![AdfMark::link(href)],
10179            )])],
10180        };
10181        let md = adf_to_markdown(&doc).unwrap();
10182        let rt = markdown_to_adf(&md).unwrap();
10183        let content = rt.content[0].content.as_ref().unwrap();
10184        assert_eq!(content.len(), 1);
10185        assert_eq!(content[0].node_type, "text");
10186        assert_eq!(content[0].text.as_deref(), Some(text));
10187    }
10188
10189    #[test]
10190    fn bracket_url_bracket_in_link_text_round_trips() {
10191        // Regression guard: a link-marked text containing brackets on both
10192        // sides of an embedded URL (with brackets of its own) must
10193        // round-trip intact.  This exercises interaction between the
10194        // URL-as-link-text fast path, bare-URL detection, and bracket
10195        // escape handling.
10196        let href = "https://y.com";
10197        let text = "[see https://x.com/a[0]=1 here]";
10198        let doc = AdfDocument {
10199            version: 1,
10200            doc_type: "doc".to_string(),
10201            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10202                text,
10203                vec![AdfMark::link(href)],
10204            )])],
10205        };
10206        let md = adf_to_markdown(&doc).unwrap();
10207        let rt = markdown_to_adf(&md).unwrap();
10208        let content = rt.content[0].content.as_ref().unwrap();
10209        assert_eq!(content.len(), 1);
10210        assert_eq!(content[0].node_type, "text");
10211        assert_eq!(content[0].text.as_deref(), Some(text));
10212        let mark = &content[0].marks.as_ref().unwrap()[0];
10213        assert_eq!(mark.mark_type, "link");
10214        assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10215    }
10216
10217    #[test]
10218    fn escape_bare_urls_applied_inside_link_text() {
10219        // White-box: when a text node carries a link mark, bare URLs in the
10220        // text must still be escaped with `\h` so the parser does not
10221        // auto-link them into an inlineCard inside the link.  Without this,
10222        // round-trip of link-marked prose containing an embedded URL
10223        // silently corrupts on re-parse (issue #551).
10224        let doc = AdfDocument {
10225            version: 1,
10226            doc_type: "doc".to_string(),
10227            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10228                "See https://example.com/",
10229                vec![AdfMark::link("https://target.example.com/")],
10230            )])],
10231        };
10232        let md = adf_to_markdown(&doc).unwrap();
10233        assert!(
10234            md.contains(r"\https://example.com/"),
10235            "bare URL inside link text must be escaped, got: {md}"
10236        );
10237    }
10238
10239    #[test]
10240    fn inline_card_still_round_trips() {
10241        // An actual inlineCard node should still round-trip correctly
10242        // (it uses :card[url] syntax, not bare URL).
10243        let adf_json = r#"{
10244            "version": 1,
10245            "type": "doc",
10246            "content": [{
10247                "type": "paragraph",
10248                "content": [
10249                    {"type": "inlineCard", "attrs": {"url": "https://example.com/page"}}
10250                ]
10251            }]
10252        }"#;
10253        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10254        let jfm = adf_to_markdown(&adf).unwrap();
10255        assert!(jfm.contains(":card[https://example.com/page]"));
10256        let roundtripped = markdown_to_adf(&jfm).unwrap();
10257        let content = roundtripped.content[0].content.as_ref().unwrap();
10258        assert_eq!(content[0].node_type, "inlineCard");
10259        assert_eq!(
10260            content[0].attrs.as_ref().unwrap()["url"],
10261            "https://example.com/page"
10262        );
10263    }
10264
10265    // ── Issue #553: inlineCard round-trip with problematic URLs ───────
10266
10267    #[test]
10268    fn url_safe_in_bracket_content_balanced() {
10269        // Balanced brackets — depth never returns to zero mid-string.
10270        assert!(url_safe_in_bracket_content("https://example.com"));
10271        assert!(url_safe_in_bracket_content("https://example.com/[id]"));
10272        assert!(url_safe_in_bracket_content("a[b[c]d]e"));
10273        assert!(url_safe_in_bracket_content(""));
10274    }
10275
10276    #[test]
10277    fn url_safe_in_bracket_content_unbalanced() {
10278        // A `]` with no prior `[` would close `:card[...]` early.
10279        assert!(!url_safe_in_bracket_content("a]b"));
10280        assert!(!url_safe_in_bracket_content("https://example.com/path]end"));
10281        // Embedded newline breaks inline directive parsing.
10282        assert!(!url_safe_in_bracket_content("a\nb"));
10283    }
10284
10285    #[test]
10286    fn inline_card_url_with_closing_bracket_round_trip() {
10287        // Issue #553 defensive fix: a URL that contains `]` (unbalanced) must
10288        // round-trip without truncation.  The renderer must switch to the
10289        // quoted attribute form `:card[]{url="..."}` so the parser's
10290        // depth-based bracket matcher does not terminate the directive early.
10291        let adf_json = r#"{
10292            "version": 1,
10293            "type": "doc",
10294            "content": [{
10295                "type": "paragraph",
10296                "content": [
10297                    {"type": "text", "text": "See: "},
10298                    {"type": "inlineCard", "attrs": {"url": "https://example.com/path]end/?q=1"}}
10299                ]
10300            }]
10301        }"#;
10302        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10303        let jfm = adf_to_markdown(&adf).unwrap();
10304        assert!(
10305            jfm.contains(r#":card[]{url="https://example.com/path]end/?q=1"}"#),
10306            "expected attr-form for URL with `]`, got: {jfm}"
10307        );
10308        let rt = markdown_to_adf(&jfm).unwrap();
10309        let content = rt.content[0].content.as_ref().unwrap();
10310        assert_eq!(content.len(), 2, "expected 2 inline nodes, got {content:?}");
10311        assert_eq!(content[0].node_type, "text");
10312        assert_eq!(content[0].text.as_deref(), Some("See: "));
10313        assert_eq!(content[1].node_type, "inlineCard");
10314        assert_eq!(
10315            content[1].attrs.as_ref().unwrap()["url"],
10316            "https://example.com/path]end/?q=1"
10317        );
10318    }
10319
10320    #[test]
10321    fn inline_card_url_with_closing_bracket_preserves_local_id() {
10322        // Attr-form `:card[]{url=... localId=...}` must preserve localId too.
10323        let adf_json = r#"{
10324            "version": 1,
10325            "type": "doc",
10326            "content": [{
10327                "type": "paragraph",
10328                "content": [
10329                    {"type": "inlineCard", "attrs": {
10330                        "url": "https://example.com/a]b",
10331                        "localId": "c-77"
10332                    }}
10333                ]
10334            }]
10335        }"#;
10336        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10337        let jfm = adf_to_markdown(&adf).unwrap();
10338        assert!(
10339            jfm.contains(r#"url="https://example.com/a]b""#),
10340            "jfm: {jfm}"
10341        );
10342        assert!(jfm.contains("localId=c-77"), "jfm: {jfm}");
10343        let rt = markdown_to_adf(&jfm).unwrap();
10344        let card = &rt.content[0].content.as_ref().unwrap()[0];
10345        assert_eq!(card.node_type, "inlineCard");
10346        assert_eq!(
10347            card.attrs.as_ref().unwrap()["url"],
10348            "https://example.com/a]b"
10349        );
10350        assert_eq!(card.attrs.as_ref().unwrap()["localId"], "c-77");
10351    }
10352
10353    #[test]
10354    fn block_card_url_with_closing_bracket_round_trip() {
10355        // Same defensive fix applied to the leaf directive `::card`.
10356        let adf_json = r#"{
10357            "version": 1,
10358            "type": "doc",
10359            "content": [
10360                {"type": "blockCard", "attrs": {"url": "https://example.com/path]end"}}
10361            ]
10362        }"#;
10363        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10364        let jfm = adf_to_markdown(&adf).unwrap();
10365        assert!(
10366            jfm.contains(r#"::card[]{url="https://example.com/path]end"}"#),
10367            "expected attr-form for blockCard with `]`, got: {jfm}"
10368        );
10369        let rt = markdown_to_adf(&jfm).unwrap();
10370        assert_eq!(rt.content[0].node_type, "blockCard");
10371        assert_eq!(
10372            rt.content[0].attrs.as_ref().unwrap()["url"],
10373            "https://example.com/path]end"
10374        );
10375    }
10376
10377    #[test]
10378    fn block_card_attr_form_parses_without_renderer() {
10379        // Directly parsing `::card[]{url="..."}` exercises the attr-URL
10380        // fallback in the leaf-directive dispatcher (covers the `url` lookup
10381        // path independently of the ADF→JFM renderer).
10382        let doc = markdown_to_adf(r#"::card[]{url="https://example.com/a"}"#).unwrap();
10383        assert_eq!(doc.content[0].node_type, "blockCard");
10384        assert_eq!(
10385            doc.content[0].attrs.as_ref().unwrap()["url"],
10386            "https://example.com/a"
10387        );
10388    }
10389
10390    #[test]
10391    fn block_card_attr_form_url_overrides_content() {
10392        // When both bracket-content and `url=` attribute are present on
10393        // `::card`, the attribute wins.  Mirrors the inline-directive
10394        // behaviour and keeps hand-edited JFM forgiving.
10395        let doc =
10396            markdown_to_adf(r#"::card[https://old.example.com]{url="https://new.example.com"}"#)
10397                .unwrap();
10398        assert_eq!(doc.content[0].node_type, "blockCard");
10399        assert_eq!(
10400            doc.content[0].attrs.as_ref().unwrap()["url"],
10401            "https://new.example.com"
10402        );
10403    }
10404
10405    #[test]
10406    fn block_card_attr_form_with_layout_and_width() {
10407        // Attr-URL form combined with layout/width attrs — ensures all
10408        // sibling attrs still pass through after the URL lookup.
10409        let doc =
10410            markdown_to_adf(r#"::card[]{url="https://example.com/a]b" layout=wide width=80}"#)
10411                .unwrap();
10412        let attrs = doc.content[0].attrs.as_ref().unwrap();
10413        assert_eq!(attrs["url"], "https://example.com/a]b");
10414        assert_eq!(attrs["layout"], "wide");
10415        assert_eq!(attrs["width"], 80);
10416    }
10417
10418    #[test]
10419    fn inline_card_issue_553_reproducer() {
10420        // Verbatim reproducer from issue #553: an inlineCard in a paragraph
10421        // with preceding text must round-trip as an inlineCard, not degrade to
10422        // a text node with a link mark.
10423        let adf_json = r#"{
10424            "version": 1,
10425            "type": "doc",
10426            "content": [{
10427                "type": "paragraph",
10428                "content": [
10429                    {"type": "text", "text": "See the related page: "},
10430                    {"type": "inlineCard", "attrs": {
10431                        "url": "https://example.atlassian.net/wiki/spaces/ENG/pages/12345678"
10432                    }}
10433                ]
10434            }]
10435        }"#;
10436        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10437        let jfm = adf_to_markdown(&adf).unwrap();
10438        let rt = markdown_to_adf(&jfm).unwrap();
10439        let content = rt.content[0].content.as_ref().unwrap();
10440        assert_eq!(content.len(), 2);
10441        assert_eq!(content[0].node_type, "text");
10442        assert_eq!(content[1].node_type, "inlineCard");
10443        assert_eq!(
10444            content[1].attrs.as_ref().unwrap()["url"],
10445            "https://example.atlassian.net/wiki/spaces/ENG/pages/12345678"
10446        );
10447    }
10448
10449    #[test]
10450    fn inline_card_attr_form_parses_even_without_renderer() {
10451        // Directly parsing `:card[]{url="..."}` should yield an inlineCard.
10452        let doc = markdown_to_adf(r#":card[]{url="https://example.com/a"}"#).unwrap();
10453        let node = &doc.content[0].content.as_ref().unwrap()[0];
10454        assert_eq!(node.node_type, "inlineCard");
10455        assert_eq!(node.attrs.as_ref().unwrap()["url"], "https://example.com/a");
10456    }
10457
10458    #[test]
10459    fn inline_card_attr_form_url_overrides_content() {
10460        // When both bracket-content and `url=` attr are present, attr wins.
10461        // This keeps the parser forgiving of hand-edited JFM where a user
10462        // copied an old bracket form but added attrs.
10463        let doc =
10464            markdown_to_adf(r#":card[https://old.example.com]{url="https://new.example.com"}"#)
10465                .unwrap();
10466        let node = &doc.content[0].content.as_ref().unwrap()[0];
10467        assert_eq!(node.node_type, "inlineCard");
10468        assert_eq!(
10469            node.attrs.as_ref().unwrap()["url"],
10470            "https://new.example.com"
10471        );
10472    }
10473
10474    // ── Issue #553 (updated): mark-wrapped URL must not become inlineCard ──
10475
10476    #[test]
10477    fn url_with_link_and_underline_marks_round_trip() {
10478        // Issue #553 (updated reproducer): a `text` node whose content is a
10479        // URL and that carries both `link` and `underline` marks must round-
10480        // trip as text+marks, not be promoted to an `inlineCard`.
10481        let adf_json = r#"{
10482            "version": 1,
10483            "type": "doc",
10484            "content": [{
10485                "type": "paragraph",
10486                "content": [
10487                    {"type": "text", "text": "See results at: "},
10488                    {"type": "text",
10489                     "text": "https://example.com/projects/abc123/analytics",
10490                     "marks": [
10491                        {"type": "link", "attrs": {"href": "https://example.com/projects/abc123/analytics"}},
10492                        {"type": "underline"}
10493                     ]},
10494                    {"type": "text", "text": " for details."}
10495                ]
10496            }]
10497        }"#;
10498        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10499        let jfm = adf_to_markdown(&adf).unwrap();
10500        let rt = markdown_to_adf(&jfm).unwrap();
10501        let content = rt.content[0].content.as_ref().unwrap();
10502        assert_eq!(
10503            content.len(),
10504            3,
10505            "expected 3 inline nodes, got: {content:?}"
10506        );
10507        assert_eq!(content[0].node_type, "text");
10508        assert_eq!(
10509            content[1].node_type, "text",
10510            "must stay text, not inlineCard"
10511        );
10512        assert_eq!(
10513            content[1].text.as_deref(),
10514            Some("https://example.com/projects/abc123/analytics")
10515        );
10516        let mark_types: Vec<&str> = content[1]
10517            .marks
10518            .as_deref()
10519            .unwrap_or(&[])
10520            .iter()
10521            .map(|m| m.mark_type.as_str())
10522            .collect();
10523        assert_eq!(mark_types, vec!["link", "underline"], "marks lost");
10524        assert_eq!(content[2].node_type, "text");
10525    }
10526
10527    #[test]
10528    fn url_inside_bracketed_span_stays_text() {
10529        // `[URL]{underline}` in JFM means "underline this URL text", not
10530        // "create a smart link that's underlined".  The nested parse_inline
10531        // call must not auto-promote the bare URL to an inlineCard.
10532        let doc = markdown_to_adf("[https://example.com]{underline}").unwrap();
10533        let node = &doc.content[0].content.as_ref().unwrap()[0];
10534        assert_eq!(node.node_type, "text");
10535        assert_eq!(node.text.as_deref(), Some("https://example.com"));
10536        let mark_types: Vec<&str> = node
10537            .marks
10538            .as_deref()
10539            .unwrap_or(&[])
10540            .iter()
10541            .map(|m| m.mark_type.as_str())
10542            .collect();
10543        assert_eq!(mark_types, vec!["underline"]);
10544    }
10545
10546    #[test]
10547    fn url_inside_emphasis_stays_text() {
10548        // Bold, italic, and strike-wrapped URLs should remain as text nodes,
10549        // not get promoted to inlineCards by the nested inline parser.
10550        for (md, mark) in [
10551            ("**https://example.com**", "strong"),
10552            ("*https://example.com*", "em"),
10553            ("~~https://example.com~~", "strike"),
10554        ] {
10555            let doc = markdown_to_adf(md).unwrap();
10556            let node = &doc.content[0].content.as_ref().unwrap()[0];
10557            assert_eq!(node.node_type, "text", "md={md}: must be text");
10558            assert_eq!(node.text.as_deref(), Some("https://example.com"));
10559            let mark_types: Vec<&str> = node
10560                .marks
10561                .as_deref()
10562                .unwrap_or(&[])
10563                .iter()
10564                .map(|m| m.mark_type.as_str())
10565                .collect();
10566            assert_eq!(mark_types, vec![mark], "md={md}: wrong marks");
10567        }
10568    }
10569
10570    #[test]
10571    fn url_inside_span_directive_stays_text() {
10572        // `:span[URL]{color=red}` should not promote the URL to an inlineCard.
10573        let doc = markdown_to_adf(":span[https://example.com]{color=red}").unwrap();
10574        let node = &doc.content[0].content.as_ref().unwrap()[0];
10575        assert_eq!(node.node_type, "text");
10576        assert_eq!(node.text.as_deref(), Some("https://example.com"));
10577        let mark = &node.marks.as_ref().unwrap()[0];
10578        assert_eq!(mark.mark_type, "textColor");
10579    }
10580
10581    #[test]
10582    fn url_as_link_text_with_underline_after_link_mark_order() {
10583        // Reverse mark order — underline appears BEFORE link in the ADF array.
10584        // The JFM form is `[[text](url)]{underline}`; the nested parser must
10585        // still keep the URL as plain text.
10586        let adf_json = r#"{
10587            "version": 1,
10588            "type": "doc",
10589            "content": [{
10590                "type": "paragraph",
10591                "content": [
10592                    {"type": "text",
10593                     "text": "https://example.com",
10594                     "marks": [
10595                        {"type": "underline"},
10596                        {"type": "link", "attrs": {"href": "https://example.com"}}
10597                     ]}
10598                ]
10599            }]
10600        }"#;
10601        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10602        let jfm = adf_to_markdown(&adf).unwrap();
10603        let rt = markdown_to_adf(&jfm).unwrap();
10604        let node = &rt.content[0].content.as_ref().unwrap()[0];
10605        assert_eq!(node.node_type, "text", "must stay text, got: {node:?}");
10606        assert_eq!(node.text.as_deref(), Some("https://example.com"));
10607        let mark_types: Vec<&str> = node
10608            .marks
10609            .as_deref()
10610            .unwrap_or(&[])
10611            .iter()
10612            .map(|m| m.mark_type.as_str())
10613            .collect();
10614        assert_eq!(mark_types, vec!["underline", "link"]);
10615    }
10616
10617    #[test]
10618    fn bare_url_at_top_level_still_becomes_inline_card() {
10619        // Regression guard: the suppression only applies inside mark-wrapping
10620        // constructs.  A bare URL in ordinary paragraph text must still be
10621        // detected and promoted to an inlineCard.
10622        let doc = markdown_to_adf("Visit https://example.com today").unwrap();
10623        let content = doc.content[0].content.as_ref().unwrap();
10624        assert_eq!(content.len(), 3);
10625        assert_eq!(content[0].node_type, "text");
10626        assert_eq!(content[1].node_type, "inlineCard");
10627        assert_eq!(
10628            content[1].attrs.as_ref().unwrap()["url"],
10629            "https://example.com"
10630        );
10631        assert_eq!(content[2].node_type, "text");
10632    }
10633
10634    // ── Block-level attribute marks (Tier 5/6) ───────────────────────
10635
10636    #[test]
10637    fn paragraph_align_center() {
10638        let md = "Centered text.\n{align=center}";
10639        let doc = markdown_to_adf(md).unwrap();
10640        let marks = doc.content[0].marks.as_ref().unwrap();
10641        assert_eq!(marks[0].mark_type, "alignment");
10642        assert_eq!(marks[0].attrs.as_ref().unwrap()["align"], "center");
10643    }
10644
10645    #[test]
10646    fn adf_alignment_to_markdown() {
10647        let mut node = AdfNode::paragraph(vec![AdfNode::text("Centered.")]);
10648        node.marks = Some(vec![AdfMark::alignment("center")]);
10649        let doc = AdfDocument {
10650            version: 1,
10651            doc_type: "doc".to_string(),
10652            content: vec![node],
10653        };
10654        let md = adf_to_markdown(&doc).unwrap();
10655        assert!(md.contains("Centered."));
10656        assert!(md.contains("{align=center}"));
10657    }
10658
10659    #[test]
10660    fn round_trip_alignment() {
10661        let md = "Centered.\n{align=center}\n";
10662        let doc = markdown_to_adf(md).unwrap();
10663        let result = adf_to_markdown(&doc).unwrap();
10664        assert!(result.contains("{align=center}"));
10665    }
10666
10667    #[test]
10668    fn paragraph_indent() {
10669        let md = "Indented.\n{indent=2}";
10670        let doc = markdown_to_adf(md).unwrap();
10671        let marks = doc.content[0].marks.as_ref().unwrap();
10672        assert_eq!(marks[0].mark_type, "indentation");
10673        assert_eq!(marks[0].attrs.as_ref().unwrap()["level"], 2);
10674    }
10675
10676    #[test]
10677    fn code_block_breakout() {
10678        let md = "```python\ndef f(): pass\n```\n{breakout=wide}";
10679        let doc = markdown_to_adf(md).unwrap();
10680        let marks = doc.content[0].marks.as_ref().unwrap();
10681        assert_eq!(marks[0].mark_type, "breakout");
10682        assert_eq!(marks[0].attrs.as_ref().unwrap()["mode"], "wide");
10683        assert!(marks[0].attrs.as_ref().unwrap().get("width").is_none());
10684    }
10685
10686    #[test]
10687    fn code_block_breakout_with_width() {
10688        let md = "```python\ndef f(): pass\n```\n{breakout=wide breakoutWidth=1200}";
10689        let doc = markdown_to_adf(md).unwrap();
10690        let marks = doc.content[0].marks.as_ref().unwrap();
10691        assert_eq!(marks[0].mark_type, "breakout");
10692        assert_eq!(marks[0].attrs.as_ref().unwrap()["mode"], "wide");
10693        assert_eq!(marks[0].attrs.as_ref().unwrap()["width"], 1200);
10694    }
10695
10696    #[test]
10697    fn adf_breakout_to_markdown() {
10698        let mut node = AdfNode::code_block(Some("python"), "pass");
10699        node.marks = Some(vec![AdfMark::breakout("wide", None)]);
10700        let doc = AdfDocument {
10701            version: 1,
10702            doc_type: "doc".to_string(),
10703            content: vec![node],
10704        };
10705        let md = adf_to_markdown(&doc).unwrap();
10706        assert!(md.contains("{breakout=wide}"));
10707        assert!(!md.contains("breakoutWidth"));
10708    }
10709
10710    #[test]
10711    fn adf_breakout_with_width_to_markdown() {
10712        let mut node = AdfNode::code_block(Some("python"), "pass");
10713        node.marks = Some(vec![AdfMark::breakout("wide", Some(1200))]);
10714        let doc = AdfDocument {
10715            version: 1,
10716            doc_type: "doc".to_string(),
10717            content: vec![node],
10718        };
10719        let md = adf_to_markdown(&doc).unwrap();
10720        assert!(md.contains("breakout=wide"));
10721        assert!(md.contains("breakoutWidth=1200"));
10722    }
10723
10724    #[test]
10725    fn breakout_width_round_trip() {
10726        let adf_json = r#"{"version":1,"type":"doc","content":[{
10727            "type":"codeBlock",
10728            "attrs":{"language":"text"},
10729            "marks":[{"type":"breakout","attrs":{"mode":"wide","width":1200}}],
10730            "content":[{"type":"text","text":"some code"}]
10731        }]}"#;
10732        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10733        let md = adf_to_markdown(&doc).unwrap();
10734        assert!(md.contains("breakout=wide"));
10735        assert!(md.contains("breakoutWidth=1200"));
10736        let round_tripped = markdown_to_adf(&md).unwrap();
10737        let marks = round_tripped.content[0].marks.as_ref().unwrap();
10738        let breakout = marks.iter().find(|m| m.mark_type == "breakout").unwrap();
10739        assert_eq!(breakout.attrs.as_ref().unwrap()["mode"], "wide");
10740        assert_eq!(breakout.attrs.as_ref().unwrap()["width"], 1200);
10741    }
10742
10743    // ── Attribute extensions — media & table (Tier 5) ────────────────
10744
10745    #[test]
10746    fn image_with_layout_attrs() {
10747        let doc = markdown_to_adf("![alt](url){layout=wide width=80}").unwrap();
10748        let node = &doc.content[0];
10749        assert_eq!(node.node_type, "mediaSingle");
10750        let attrs = node.attrs.as_ref().unwrap();
10751        assert_eq!(attrs["layout"], "wide");
10752        assert_eq!(attrs["width"], 80);
10753    }
10754
10755    #[test]
10756    fn adf_image_with_layout_to_markdown() {
10757        let mut node = AdfNode::media_single("url", Some("alt"));
10758        node.attrs.as_mut().unwrap()["layout"] = serde_json::json!("wide");
10759        node.attrs.as_mut().unwrap()["width"] = serde_json::json!(80);
10760        let doc = AdfDocument {
10761            version: 1,
10762            doc_type: "doc".to_string(),
10763            content: vec![node],
10764        };
10765        let md = adf_to_markdown(&doc).unwrap();
10766        assert!(md.contains("![alt](url){layout=wide width=80}"));
10767    }
10768
10769    #[test]
10770    fn table_with_layout_attrs() {
10771        let md = "| H |\n| --- |\n| C |\n{layout=wide numbered}";
10772        let doc = markdown_to_adf(md).unwrap();
10773        let table = &doc.content[0];
10774        assert_eq!(table.node_type, "table");
10775        let attrs = table.attrs.as_ref().unwrap();
10776        assert_eq!(attrs["layout"], "wide");
10777        assert_eq!(attrs["isNumberColumnEnabled"], true);
10778    }
10779
10780    #[test]
10781    fn adf_table_with_attrs_to_markdown() {
10782        let mut table = AdfNode::table(vec![
10783            AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
10784                AdfNode::text("H"),
10785            ])])]),
10786            AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
10787                AdfNode::text("C"),
10788            ])])]),
10789        ]);
10790        table.attrs = Some(serde_json::json!({"layout": "wide", "isNumberColumnEnabled": true}));
10791        let doc = AdfDocument {
10792            version: 1,
10793            doc_type: "doc".to_string(),
10794            content: vec![table],
10795        };
10796        let md = adf_to_markdown(&doc).unwrap();
10797        assert!(md.contains("{layout=wide numbered}"));
10798    }
10799
10800    // ── Attribute extensions — inline marks (Tier 5) ─────────────────
10801
10802    #[test]
10803    fn underline_bracketed_span() {
10804        let doc = markdown_to_adf("This is [underlined text]{underline} here.").unwrap();
10805        let content = doc.content[0].content.as_ref().unwrap();
10806        assert_eq!(content[1].text.as_deref(), Some("underlined text"));
10807        let marks = content[1].marks.as_ref().unwrap();
10808        assert_eq!(marks[0].mark_type, "underline");
10809    }
10810
10811    #[test]
10812    fn adf_underline_to_markdown() {
10813        let doc = AdfDocument {
10814            version: 1,
10815            doc_type: "doc".to_string(),
10816            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10817                "underlined",
10818                vec![AdfMark::underline()],
10819            )])],
10820        };
10821        let md = adf_to_markdown(&doc).unwrap();
10822        assert!(md.contains("[underlined]{underline}"));
10823    }
10824
10825    #[test]
10826    fn round_trip_underline() {
10827        let md = "This is [underlined text]{underline} here.\n";
10828        let doc = markdown_to_adf(md).unwrap();
10829        let result = adf_to_markdown(&doc).unwrap();
10830        assert!(result.contains("[underlined text]{underline}"));
10831    }
10832
10833    #[test]
10834    fn mark_ordering_underline_strong_preserved() {
10835        // Issue #383: mark ordering was non-deterministic
10836        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10837          {"type":"text","text":"bold and underlined","marks":[{"type":"underline"},{"type":"strong"}]}
10838        ]}]}"#;
10839        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10840        let md = adf_to_markdown(&doc).unwrap();
10841        let round_tripped = markdown_to_adf(&md).unwrap();
10842        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
10843        let mark_types: Vec<&str> = node
10844            .marks
10845            .as_ref()
10846            .unwrap()
10847            .iter()
10848            .map(|m| m.mark_type.as_str())
10849            .collect();
10850        assert_eq!(
10851            mark_types,
10852            vec!["underline", "strong"],
10853            "mark order should be preserved, got: {mark_types:?}"
10854        );
10855    }
10856
10857    #[test]
10858    fn mark_ordering_link_strong_preserved() {
10859        // Issue #403: link+strong mark order was swapped on round-trip
10860        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10861          {"type":"text","text":"bold link","marks":[
10862            {"type":"link","attrs":{"href":"https://example.com"}},
10863            {"type":"strong"}
10864          ]}
10865        ]}]}"#;
10866        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10867        let md = adf_to_markdown(&doc).unwrap();
10868        let round_tripped = markdown_to_adf(&md).unwrap();
10869        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
10870        let mark_types: Vec<&str> = node
10871            .marks
10872            .as_ref()
10873            .unwrap()
10874            .iter()
10875            .map(|m| m.mark_type.as_str())
10876            .collect();
10877        assert_eq!(
10878            mark_types,
10879            vec!["link", "strong"],
10880            "mark order should be preserved, got: {mark_types:?}"
10881        );
10882    }
10883
10884    #[test]
10885    fn mark_ordering_link_textcolor_preserved() {
10886        // Issue #403 comment: link+textColor mark order was swapped on round-trip
10887        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10888          {"type":"text","text":"red link","marks":[
10889            {"type":"link","attrs":{"href":"https://example.com"}},
10890            {"type":"textColor","attrs":{"color":"#ff0000"}}
10891          ]}
10892        ]}]}"##;
10893        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10894        let md = adf_to_markdown(&doc).unwrap();
10895        let round_tripped = markdown_to_adf(&md).unwrap();
10896        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
10897        let mark_types: Vec<&str> = node
10898            .marks
10899            .as_ref()
10900            .unwrap()
10901            .iter()
10902            .map(|m| m.mark_type.as_str())
10903            .collect();
10904        assert_eq!(
10905            mark_types,
10906            vec!["link", "textColor"],
10907            "mark order should be preserved, got: {mark_types:?}"
10908        );
10909    }
10910
10911    #[test]
10912    fn mark_ordering_link_em_preserved() {
10913        // Issue #403: link+em mark order should be preserved
10914        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10915          {"type":"text","text":"italic link","marks":[
10916            {"type":"link","attrs":{"href":"https://example.com"}},
10917            {"type":"em"}
10918          ]}
10919        ]}]}"#;
10920        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10921        let md = adf_to_markdown(&doc).unwrap();
10922        let round_tripped = markdown_to_adf(&md).unwrap();
10923        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
10924        let mark_types: Vec<&str> = node
10925            .marks
10926            .as_ref()
10927            .unwrap()
10928            .iter()
10929            .map(|m| m.mark_type.as_str())
10930            .collect();
10931        assert_eq!(
10932            mark_types,
10933            vec!["link", "em"],
10934            "mark order should be preserved, got: {mark_types:?}"
10935        );
10936    }
10937
10938    #[test]
10939    fn mark_ordering_link_strike_preserved() {
10940        // Issue #403: link+strike mark order should be preserved
10941        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10942          {"type":"text","text":"struck link","marks":[
10943            {"type":"link","attrs":{"href":"https://example.com"}},
10944            {"type":"strike"}
10945          ]}
10946        ]}]}"#;
10947        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10948        let md = adf_to_markdown(&doc).unwrap();
10949        let round_tripped = markdown_to_adf(&md).unwrap();
10950        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
10951        let mark_types: Vec<&str> = node
10952            .marks
10953            .as_ref()
10954            .unwrap()
10955            .iter()
10956            .map(|m| m.mark_type.as_str())
10957            .collect();
10958        assert_eq!(
10959            mark_types,
10960            vec!["link", "strike"],
10961            "mark order should be preserved, got: {mark_types:?}"
10962        );
10963    }
10964
10965    #[test]
10966    fn mark_ordering_strong_link_preserved() {
10967        // Issue #403: [strong, link] order must also be preserved (reverse direction)
10968        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10969          {"type":"text","text":"bold link","marks":[
10970            {"type":"strong"},
10971            {"type":"link","attrs":{"href":"https://example.com"}}
10972          ]}
10973        ]}]}"#;
10974        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10975        let md = adf_to_markdown(&doc).unwrap();
10976        let round_tripped = markdown_to_adf(&md).unwrap();
10977        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
10978        let mark_types: Vec<&str> = node
10979            .marks
10980            .as_ref()
10981            .unwrap()
10982            .iter()
10983            .map(|m| m.mark_type.as_str())
10984            .collect();
10985        assert_eq!(
10986            mark_types,
10987            vec!["strong", "link"],
10988            "mark order should be preserved, got: {mark_types:?}"
10989        );
10990    }
10991
10992    #[test]
10993    fn mark_ordering_em_link_preserved() {
10994        // Issue #403: [em, link] order must also be preserved
10995        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10996          {"type":"text","text":"italic link","marks":[
10997            {"type":"em"},
10998            {"type":"link","attrs":{"href":"https://example.com"}}
10999          ]}
11000        ]}]}"#;
11001        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11002        let md = adf_to_markdown(&doc).unwrap();
11003        let round_tripped = markdown_to_adf(&md).unwrap();
11004        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11005        let mark_types: Vec<&str> = node
11006            .marks
11007            .as_ref()
11008            .unwrap()
11009            .iter()
11010            .map(|m| m.mark_type.as_str())
11011            .collect();
11012        assert_eq!(
11013            mark_types,
11014            vec!["em", "link"],
11015            "mark order should be preserved, got: {mark_types:?}"
11016        );
11017    }
11018
11019    #[test]
11020    fn mark_ordering_strike_link_preserved() {
11021        // Issue #403: [strike, link] order must also be preserved
11022        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11023          {"type":"text","text":"struck link","marks":[
11024            {"type":"strike"},
11025            {"type":"link","attrs":{"href":"https://example.com"}}
11026          ]}
11027        ]}]}"#;
11028        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11029        let md = adf_to_markdown(&doc).unwrap();
11030        let round_tripped = markdown_to_adf(&md).unwrap();
11031        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11032        let mark_types: Vec<&str> = node
11033            .marks
11034            .as_ref()
11035            .unwrap()
11036            .iter()
11037            .map(|m| m.mark_type.as_str())
11038            .collect();
11039        assert_eq!(
11040            mark_types,
11041            vec!["strike", "link"],
11042            "mark order should be preserved, got: {mark_types:?}"
11043        );
11044    }
11045
11046    #[test]
11047    fn mark_ordering_underline_link_preserved() {
11048        // Issue #403: [underline, link] order must be preserved
11049        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11050          {"type":"text","text":"click here","marks":[
11051            {"type":"underline"},
11052            {"type":"link","attrs":{"href":"https://example.com"}}
11053          ]}
11054        ]}]}"#;
11055        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11056        let md = adf_to_markdown(&doc).unwrap();
11057        let round_tripped = markdown_to_adf(&md).unwrap();
11058        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11059        let mark_types: Vec<&str> = node
11060            .marks
11061            .as_ref()
11062            .unwrap()
11063            .iter()
11064            .map(|m| m.mark_type.as_str())
11065            .collect();
11066        assert_eq!(
11067            mark_types,
11068            vec!["underline", "link"],
11069            "mark order should be preserved, got: {mark_types:?}"
11070        );
11071    }
11072
11073    #[test]
11074    fn mark_ordering_textcolor_link_preserved() {
11075        // Issue #403: [textColor, link] order must be preserved
11076        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11077          {"type":"text","text":"red link","marks":[
11078            {"type":"textColor","attrs":{"color":"#ff0000"}},
11079            {"type":"link","attrs":{"href":"https://example.com"}}
11080          ]}
11081        ]}]}"##;
11082        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11083        let md = adf_to_markdown(&doc).unwrap();
11084        let round_tripped = markdown_to_adf(&md).unwrap();
11085        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11086        let mark_types: Vec<&str> = node
11087            .marks
11088            .as_ref()
11089            .unwrap()
11090            .iter()
11091            .map(|m| m.mark_type.as_str())
11092            .collect();
11093        assert_eq!(
11094            mark_types,
11095            vec!["textColor", "link"],
11096            "mark order should be preserved, got: {mark_types:?}"
11097        );
11098    }
11099
11100    #[test]
11101    fn mark_ordering_link_underline_preserved() {
11102        // Issue #403: [link, underline] order must be preserved (link wraps bracketed span)
11103        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11104          {"type":"text","text":"click here","marks":[
11105            {"type":"link","attrs":{"href":"https://example.com"}},
11106            {"type":"underline"}
11107          ]}
11108        ]}]}"#;
11109        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11110        let md = adf_to_markdown(&doc).unwrap();
11111        // Link should wrap the underline bracketed span: [[click here]{underline}](url)
11112        assert!(
11113            md.contains("](https://example.com)"),
11114            "should have link: {md}"
11115        );
11116        assert!(md.contains("underline"), "should have underline: {md}");
11117        let round_tripped = markdown_to_adf(&md).unwrap();
11118        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11119        let mark_types: Vec<&str> = node
11120            .marks
11121            .as_ref()
11122            .unwrap()
11123            .iter()
11124            .map(|m| m.mark_type.as_str())
11125            .collect();
11126        assert_eq!(
11127            mark_types,
11128            vec!["link", "underline"],
11129            "mark order should be preserved, got: {mark_types:?}"
11130        );
11131    }
11132
11133    #[test]
11134    fn mark_ordering_underline_strong_link_preserved() {
11135        // Issue #491: [underline, strong, link] reordered to [strong, underline, link]
11136        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11137          {"type":"text","text":"bold underlined link","marks":[
11138            {"type":"underline"},
11139            {"type":"strong"},
11140            {"type":"link","attrs":{"href":"https://example.com/page"}}
11141          ]}
11142        ]}]}"#;
11143        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11144        let md = adf_to_markdown(&doc).unwrap();
11145        let round_tripped = markdown_to_adf(&md).unwrap();
11146        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11147        let mark_types: Vec<&str> = node
11148            .marks
11149            .as_ref()
11150            .unwrap()
11151            .iter()
11152            .map(|m| m.mark_type.as_str())
11153            .collect();
11154        assert_eq!(
11155            mark_types,
11156            vec!["underline", "strong", "link"],
11157            "mark order should be preserved, got: {mark_types:?}"
11158        );
11159    }
11160
11161    #[test]
11162    fn mark_ordering_strong_underline_link_preserved() {
11163        // Issue #491: verify [strong, underline, link] is preserved
11164        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11165          {"type":"text","text":"bold underlined link","marks":[
11166            {"type":"strong"},
11167            {"type":"underline"},
11168            {"type":"link","attrs":{"href":"https://example.com/page"}}
11169          ]}
11170        ]}]}"#;
11171        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11172        let md = adf_to_markdown(&doc).unwrap();
11173        let round_tripped = markdown_to_adf(&md).unwrap();
11174        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11175        let mark_types: Vec<&str> = node
11176            .marks
11177            .as_ref()
11178            .unwrap()
11179            .iter()
11180            .map(|m| m.mark_type.as_str())
11181            .collect();
11182        assert_eq!(
11183            mark_types,
11184            vec!["strong", "underline", "link"],
11185            "mark order should be preserved, got: {mark_types:?}"
11186        );
11187    }
11188
11189    #[test]
11190    fn mark_ordering_underline_em_link_preserved() {
11191        // Issue #491: verify [underline, em, link] is preserved
11192        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11193          {"type":"text","text":"italic underlined link","marks":[
11194            {"type":"underline"},
11195            {"type":"em"},
11196            {"type":"link","attrs":{"href":"https://example.com/page"}}
11197          ]}
11198        ]}]}"#;
11199        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11200        let md = adf_to_markdown(&doc).unwrap();
11201        let round_tripped = markdown_to_adf(&md).unwrap();
11202        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11203        let mark_types: Vec<&str> = node
11204            .marks
11205            .as_ref()
11206            .unwrap()
11207            .iter()
11208            .map(|m| m.mark_type.as_str())
11209            .collect();
11210        assert_eq!(
11211            mark_types,
11212            vec!["underline", "em", "link"],
11213            "mark order should be preserved, got: {mark_types:?}"
11214        );
11215    }
11216
11217    #[test]
11218    fn mark_ordering_underline_strike_link_preserved() {
11219        // Issue #491: verify [underline, strike, link] is preserved
11220        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11221          {"type":"text","text":"struck underlined link","marks":[
11222            {"type":"underline"},
11223            {"type":"strike"},
11224            {"type":"link","attrs":{"href":"https://example.com/page"}}
11225          ]}
11226        ]}]}"#;
11227        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11228        let md = adf_to_markdown(&doc).unwrap();
11229        let round_tripped = markdown_to_adf(&md).unwrap();
11230        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11231        let mark_types: Vec<&str> = node
11232            .marks
11233            .as_ref()
11234            .unwrap()
11235            .iter()
11236            .map(|m| m.mark_type.as_str())
11237            .collect();
11238        assert_eq!(
11239            mark_types,
11240            vec!["underline", "strike", "link"],
11241            "mark order should be preserved, got: {mark_types:?}"
11242        );
11243    }
11244
11245    #[test]
11246    fn mark_ordering_underline_strong_em_link_preserved() {
11247        // Issue #491: verify four-mark combo [underline, strong, em, link] is preserved
11248        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11249          {"type":"text","text":"all the marks","marks":[
11250            {"type":"underline"},
11251            {"type":"strong"},
11252            {"type":"em"},
11253            {"type":"link","attrs":{"href":"https://example.com/page"}}
11254          ]}
11255        ]}]}"#;
11256        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11257        let md = adf_to_markdown(&doc).unwrap();
11258        let round_tripped = markdown_to_adf(&md).unwrap();
11259        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11260        let mark_types: Vec<&str> = node
11261            .marks
11262            .as_ref()
11263            .unwrap()
11264            .iter()
11265            .map(|m| m.mark_type.as_str())
11266            .collect();
11267        assert_eq!(
11268            mark_types,
11269            vec!["underline", "strong", "em", "link"],
11270            "mark order should be preserved, got: {mark_types:?}"
11271        );
11272    }
11273
11274    #[test]
11275    fn em_strong_round_trip() {
11276        // Issue #401: em mark dropped when combined with strong
11277        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11278          {"type":"text","text":"bold and italic","marks":[{"type":"strong"},{"type":"em"}]}
11279        ]}]}"#;
11280        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11281        let md = adf_to_markdown(&doc).unwrap();
11282        assert_eq!(md.trim(), "***bold and italic***");
11283        let round_tripped = markdown_to_adf(&md).unwrap();
11284        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11285        assert_eq!(node.text.as_deref(), Some("bold and italic"));
11286        let mark_types: Vec<&str> = node
11287            .marks
11288            .as_ref()
11289            .unwrap()
11290            .iter()
11291            .map(|m| m.mark_type.as_str())
11292            .collect();
11293        assert_eq!(
11294            mark_types,
11295            vec!["strong", "em"],
11296            "both strong and em marks should be preserved, got: {mark_types:?}"
11297        );
11298    }
11299
11300    #[test]
11301    fn em_strong_round_trip_em_first() {
11302        // Issue #549: [em, strong] mark order must be preserved on round-trip.
11303        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11304          {"type":"text","text":"italic and bold","marks":[{"type":"em"},{"type":"strong"}]}
11305        ]}]}"#;
11306        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11307        let md = adf_to_markdown(&doc).unwrap();
11308        let round_tripped = markdown_to_adf(&md).unwrap();
11309        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11310        assert_eq!(node.text.as_deref(), Some("italic and bold"));
11311        let mark_types: Vec<&str> = node
11312            .marks
11313            .as_ref()
11314            .unwrap()
11315            .iter()
11316            .map(|m| m.mark_type.as_str())
11317            .collect();
11318        assert_eq!(
11319            mark_types,
11320            vec!["em", "strong"],
11321            "mark order [em, strong] should be preserved, got: {mark_types:?}"
11322        );
11323    }
11324
11325    /// Round-trips an inline text node with the given marks through ADF → JFM → ADF
11326    /// and asserts the resulting mark types match `expected`.
11327    fn assert_mark_order_round_trip(marks: Vec<AdfMark>, expected: &[&str]) {
11328        let doc = AdfDocument {
11329            version: 1,
11330            doc_type: "doc".to_string(),
11331            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11332                "text", marks,
11333            )])],
11334        };
11335        let md = adf_to_markdown(&doc).unwrap();
11336        let round_tripped = markdown_to_adf(&md).unwrap();
11337        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11338        let mark_types: Vec<&str> = node
11339            .marks
11340            .as_ref()
11341            .expect("round-tripped node should have marks")
11342            .iter()
11343            .map(|m| m.mark_type.as_str())
11344            .collect();
11345        assert_eq!(
11346            mark_types, expected,
11347            "marks should round-trip in order via {md:?}"
11348        );
11349    }
11350
11351    #[test]
11352    fn round_trip_em_strong_mark_order() {
11353        // Issue #549: em + strong in either order must round-trip.
11354        assert_mark_order_round_trip(vec![AdfMark::em(), AdfMark::strong()], &["em", "strong"]);
11355        assert_mark_order_round_trip(vec![AdfMark::strong(), AdfMark::em()], &["strong", "em"]);
11356    }
11357
11358    #[test]
11359    fn round_trip_strong_underline_mark_order() {
11360        // Issue #549: strong + underline in either order must round-trip.
11361        assert_mark_order_round_trip(
11362            vec![AdfMark::strong(), AdfMark::underline()],
11363            &["strong", "underline"],
11364        );
11365        assert_mark_order_round_trip(
11366            vec![AdfMark::underline(), AdfMark::strong()],
11367            &["underline", "strong"],
11368        );
11369    }
11370
11371    #[test]
11372    fn round_trip_em_underline_mark_order() {
11373        assert_mark_order_round_trip(
11374            vec![AdfMark::em(), AdfMark::underline()],
11375            &["em", "underline"],
11376        );
11377        assert_mark_order_round_trip(
11378            vec![AdfMark::underline(), AdfMark::em()],
11379            &["underline", "em"],
11380        );
11381    }
11382
11383    #[test]
11384    fn round_trip_strike_strong_em_permutations() {
11385        // Each permutation of {strike, strong, em} must round-trip the mark order
11386        // exactly, because the Atlassian ADF spec does not define a canonical mark
11387        // ordering and we preserve whatever ordering Jira delivered.
11388        assert_mark_order_round_trip(
11389            vec![AdfMark::strike(), AdfMark::strong(), AdfMark::em()],
11390            &["strike", "strong", "em"],
11391        );
11392        assert_mark_order_round_trip(
11393            vec![AdfMark::strike(), AdfMark::em(), AdfMark::strong()],
11394            &["strike", "em", "strong"],
11395        );
11396        assert_mark_order_round_trip(
11397            vec![AdfMark::strong(), AdfMark::strike(), AdfMark::em()],
11398            &["strong", "strike", "em"],
11399        );
11400        assert_mark_order_round_trip(
11401            vec![AdfMark::strong(), AdfMark::em(), AdfMark::strike()],
11402            &["strong", "em", "strike"],
11403        );
11404        assert_mark_order_round_trip(
11405            vec![AdfMark::em(), AdfMark::strike(), AdfMark::strong()],
11406            &["em", "strike", "strong"],
11407        );
11408        assert_mark_order_round_trip(
11409            vec![AdfMark::em(), AdfMark::strong(), AdfMark::strike()],
11410            &["em", "strong", "strike"],
11411        );
11412    }
11413
11414    #[test]
11415    fn round_trip_underline_nested_with_strong_em() {
11416        // Underline may sit outside, between, or inside strong/em — each position
11417        // must round-trip.
11418        assert_mark_order_round_trip(
11419            vec![AdfMark::underline(), AdfMark::strong(), AdfMark::em()],
11420            &["underline", "strong", "em"],
11421        );
11422        assert_mark_order_round_trip(
11423            vec![AdfMark::strong(), AdfMark::underline(), AdfMark::em()],
11424            &["strong", "underline", "em"],
11425        );
11426        assert_mark_order_round_trip(
11427            vec![AdfMark::strong(), AdfMark::em(), AdfMark::underline()],
11428            &["strong", "em", "underline"],
11429        );
11430    }
11431
11432    #[test]
11433    fn round_trip_span_attr_order_preserved() {
11434        // Issue #549: the `:span` directive always parses color/bg/subsup
11435        // attrs in a fixed order, so non-canonical orderings must be emitted
11436        // as nested :span wrappers rather than a single merged wrapper.
11437        assert_mark_order_round_trip(
11438            vec![
11439                AdfMark::background_color("#ffff00"),
11440                AdfMark::text_color("#ff0000"),
11441            ],
11442            &["backgroundColor", "textColor"],
11443        );
11444        assert_mark_order_round_trip(
11445            vec![AdfMark::subsup("sub"), AdfMark::text_color("#ff0000")],
11446            &["subsup", "textColor"],
11447        );
11448        assert_mark_order_round_trip(
11449            vec![
11450                AdfMark::text_color("#ff0000"),
11451                AdfMark::background_color("#ffff00"),
11452            ],
11453            &["textColor", "backgroundColor"],
11454        );
11455    }
11456
11457    #[test]
11458    fn round_trip_annotation_before_underline() {
11459        // Issue #549: the bracketed-span parser reads `underline` before any
11460        // annotation-ids, so `[annotation, underline]` must be emitted as
11461        // nested wrappers rather than one merged `[text]{underline annotation-id=X}`.
11462        assert_mark_order_round_trip(
11463            vec![
11464                AdfMark::annotation("ann-1", "inlineComment"),
11465                AdfMark::underline(),
11466            ],
11467            &["annotation", "underline"],
11468        );
11469        assert_mark_order_round_trip(
11470            vec![
11471                AdfMark::annotation("ann-1", "inlineComment"),
11472                AdfMark::underline(),
11473                AdfMark::annotation("ann-2", "inlineComment"),
11474            ],
11475            &["annotation", "underline", "annotation"],
11476        );
11477    }
11478
11479    #[test]
11480    fn round_trip_em_content_with_underscores() {
11481        // When em renders as `_..._` (to disambiguate from strong), any literal
11482        // underscores in the text must be escaped so they don't close the
11483        // emphasis span early.  Text like "foo_bar_baz" with [em, strong] must
11484        // survive round-trip with the underscores intact.
11485        let doc = AdfDocument {
11486            version: 1,
11487            doc_type: "doc".to_string(),
11488            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11489                "foo _bar_ baz",
11490                vec![AdfMark::em(), AdfMark::strong()],
11491            )])],
11492        };
11493        let md = adf_to_markdown(&doc).unwrap();
11494        let round_tripped = markdown_to_adf(&md).unwrap();
11495        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11496        assert_eq!(node.text.as_deref(), Some("foo _bar_ baz"));
11497        let mark_types: Vec<&str> = node
11498            .marks
11499            .as_ref()
11500            .unwrap()
11501            .iter()
11502            .map(|m| m.mark_type.as_str())
11503            .collect();
11504        assert_eq!(mark_types, vec!["em", "strong"]);
11505    }
11506
11507    #[test]
11508    fn round_trip_link_nested_with_formatting_marks() {
11509        // Link may sit at any position in the marks array relative to em,
11510        // strong, strike, and underline — each position must round-trip.
11511        assert_mark_order_round_trip(
11512            vec![
11513                AdfMark::link("https://example.com"),
11514                AdfMark::strong(),
11515                AdfMark::em(),
11516            ],
11517            &["link", "strong", "em"],
11518        );
11519        assert_mark_order_round_trip(
11520            vec![
11521                AdfMark::em(),
11522                AdfMark::strong(),
11523                AdfMark::link("https://example.com"),
11524            ],
11525            &["em", "strong", "link"],
11526        );
11527        assert_mark_order_round_trip(
11528            vec![
11529                AdfMark::underline(),
11530                AdfMark::link("https://example.com"),
11531                AdfMark::strong(),
11532            ],
11533            &["underline", "link", "strong"],
11534        );
11535    }
11536
11537    /// Builds an `AdfMark` with the given type and no attrs, bypassing the
11538    /// usual constructors so we can exercise the defensive branches in the
11539    /// render helpers (the constructors always populate `attrs`).
11540    fn bare_mark(mark_type: &str) -> AdfMark {
11541        AdfMark {
11542            mark_type: mark_type.to_string(),
11543            attrs: None,
11544        }
11545    }
11546
11547    #[test]
11548    fn collect_span_attr_handles_missing_attrs() {
11549        // `textColor`/`backgroundColor`/`subsup` marks without the expected
11550        // `color`/`type` attr must not emit a fragment (the `if let` falls
11551        // through without pushing).  This exercises the inner-None branches
11552        // that the typed-constructor tests otherwise skip.
11553        let mut attrs = Vec::new();
11554        collect_span_attr(&bare_mark("textColor"), &mut attrs);
11555        collect_span_attr(&bare_mark("backgroundColor"), &mut attrs);
11556        collect_span_attr(&bare_mark("subsup"), &mut attrs);
11557        collect_span_attr(&bare_mark("link"), &mut attrs);
11558        assert!(attrs.is_empty(), "got: {attrs:?}");
11559    }
11560
11561    #[test]
11562    fn collect_bracketed_attr_handles_missing_attrs() {
11563        // An annotation mark with no attrs map at all must silently produce
11564        // no fragments — this covers the outer `if let Some(ref a)` None arm.
11565        let mut attrs = Vec::new();
11566        collect_bracketed_attr(&bare_mark("annotation"), &mut attrs);
11567        collect_bracketed_attr(&bare_mark("strong"), &mut attrs);
11568        assert!(attrs.is_empty(), "got: {attrs:?}");
11569    }
11570
11571    #[test]
11572    fn collect_bracketed_attr_handles_annotation_without_id() {
11573        // An annotation mark with attrs present but missing `id` and
11574        // `annotationType` keys still emits nothing — exercises the inner
11575        // None branches of each `if let` in the annotation arm.
11576        let mark = AdfMark {
11577            mark_type: "annotation".to_string(),
11578            attrs: Some(serde_json::json!({})),
11579        };
11580        let mut attrs = Vec::new();
11581        collect_bracketed_attr(&mark, &mut attrs);
11582        assert!(attrs.is_empty(), "got: {attrs:?}");
11583    }
11584
11585    #[test]
11586    fn span_attr_order_rejects_unknown_types() {
11587        // `span_attr_order` must classify unknown mark types as the sentinel
11588        // value, and `span_run_is_canonical` must reject a run that contains
11589        // any such unknown type.
11590        assert_eq!(span_attr_order("textColor"), 0);
11591        assert_eq!(span_attr_order("backgroundColor"), 1);
11592        assert_eq!(span_attr_order("subsup"), 2);
11593        assert_eq!(span_attr_order("strong"), u8::MAX);
11594        assert!(!span_run_is_canonical(&[bare_mark("strong")]));
11595    }
11596
11597    #[test]
11598    fn bracketed_run_rejects_unknown_types() {
11599        // `bracketed_run_is_canonical` only accepts `underline` and
11600        // `annotation`; any other mark type in the run short-circuits to
11601        // `false` so the caller emits nested wrappers.
11602        assert!(bracketed_run_is_canonical(&[
11603            AdfMark::underline(),
11604            AdfMark::annotation("x", "inlineComment")
11605        ]));
11606        assert!(!bracketed_run_is_canonical(&[
11607            AdfMark::annotation("x", "inlineComment"),
11608            AdfMark::underline()
11609        ]));
11610        assert!(!bracketed_run_is_canonical(&[bare_mark("strong")]));
11611    }
11612
11613    #[test]
11614    fn render_marked_text_ignores_unknown_mark_types() {
11615        // Unknown mark types fall through `render_marked_text`'s `_ =>`
11616        // arm and are dropped; the rendered JFM must still produce the
11617        // underlying text (and round-trip back to an unmarked text node).
11618        let doc = AdfDocument {
11619            version: 1,
11620            doc_type: "doc".to_string(),
11621            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11622                "hello",
11623                vec![bare_mark("futureMark"), AdfMark::strong()],
11624            )])],
11625        };
11626        let md = adf_to_markdown(&doc).unwrap();
11627        assert_eq!(md.trim(), "**hello**");
11628        let rt = markdown_to_adf(&md).unwrap();
11629        let node = &rt.content[0].content.as_ref().unwrap()[0];
11630        assert_eq!(node.text.as_deref(), Some("hello"));
11631        let mark_types: Vec<&str> = node
11632            .marks
11633            .as_ref()
11634            .unwrap()
11635            .iter()
11636            .map(|m| m.mark_type.as_str())
11637            .collect();
11638        assert_eq!(mark_types, vec!["strong"]);
11639    }
11640
11641    #[test]
11642    fn triple_asterisk_parse_to_adf() {
11643        // Issue #401: ***text*** should parse as text with strong+em marks
11644        let md = "***bold and italic***\n";
11645        let doc = markdown_to_adf(md).unwrap();
11646        let node = &doc.content[0].content.as_ref().unwrap()[0];
11647        assert_eq!(node.text.as_deref(), Some("bold and italic"));
11648        let mark_types: Vec<&str> = node
11649            .marks
11650            .as_ref()
11651            .unwrap()
11652            .iter()
11653            .map(|m| m.mark_type.as_str())
11654            .collect();
11655        assert!(
11656            mark_types.contains(&"strong") && mark_types.contains(&"em"),
11657            "***text*** should produce both strong and em marks, got: {mark_types:?}"
11658        );
11659    }
11660
11661    #[test]
11662    fn triple_asterisk_with_surrounding_text() {
11663        // Issue #401: surrounding text should not be affected
11664        let md = "before ***bold italic*** after\n";
11665        let doc = markdown_to_adf(md).unwrap();
11666        let nodes = doc.content[0].content.as_ref().unwrap();
11667        // Should have: "before " (plain), "bold italic" (strong+em), " after" (plain)
11668        assert!(
11669            nodes.len() >= 3,
11670            "expected at least 3 nodes, got {}",
11671            nodes.len()
11672        );
11673        assert_eq!(nodes[0].text.as_deref(), Some("before "));
11674        assert_eq!(nodes[1].text.as_deref(), Some("bold italic"));
11675        let mark_types: Vec<&str> = nodes[1]
11676            .marks
11677            .as_ref()
11678            .unwrap()
11679            .iter()
11680            .map(|m| m.mark_type.as_str())
11681            .collect();
11682        assert!(
11683            mark_types.contains(&"strong") && mark_types.contains(&"em"),
11684            "middle node should have strong+em, got: {mark_types:?}"
11685        );
11686        assert_eq!(nodes[2].text.as_deref(), Some(" after"));
11687    }
11688
11689    #[test]
11690    fn annotation_mark_round_trip() {
11691        // Issue #364: annotation marks were silently dropped
11692        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11693          {"type":"text","text":"highlighted text","marks":[
11694            {"type":"annotation","attrs":{"id":"abc123","annotationType":"inlineComment"}}
11695          ]}
11696        ]}]}"#;
11697        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11698
11699        let md = adf_to_markdown(&doc).unwrap();
11700        assert!(
11701            md.contains("annotation-id="),
11702            "JFM should contain annotation-id, got: {md}"
11703        );
11704
11705        let round_tripped = markdown_to_adf(&md).unwrap();
11706        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11707        assert_eq!(text_node.text.as_deref(), Some("highlighted text"));
11708        let marks = text_node.marks.as_ref().expect("should have marks");
11709        let ann = marks
11710            .iter()
11711            .find(|m| m.mark_type == "annotation")
11712            .expect("should have annotation mark");
11713        let attrs = ann.attrs.as_ref().unwrap();
11714        assert_eq!(attrs["id"], "abc123");
11715        assert_eq!(attrs["annotationType"], "inlineComment");
11716    }
11717
11718    #[test]
11719    fn annotation_mark_with_bold() {
11720        // Annotation + bold should both survive round-trip
11721        let doc = AdfDocument {
11722            version: 1,
11723            doc_type: "doc".to_string(),
11724            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11725                "bold comment",
11726                vec![
11727                    AdfMark::strong(),
11728                    AdfMark::annotation("def456", "inlineComment"),
11729                ],
11730            )])],
11731        };
11732        let md = adf_to_markdown(&doc).unwrap();
11733        let round_tripped = markdown_to_adf(&md).unwrap();
11734        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11735        let marks = text_node.marks.as_ref().expect("should have marks");
11736        assert!(
11737            marks.iter().any(|m| m.mark_type == "strong"),
11738            "should have strong mark"
11739        );
11740        assert!(
11741            marks.iter().any(|m| m.mark_type == "annotation"),
11742            "should have annotation mark"
11743        );
11744    }
11745
11746    #[test]
11747    fn annotation_and_link_marks_both_preserved() {
11748        // Issue #390: text with both annotation and link marks loses link on round-trip
11749        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11750          {"type":"text","text":"HANGUL-8","marks":[
11751            {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"5ca7425e-34cd-48d3-b4eb-9873ac8b20e0"}},
11752            {"type":"link","attrs":{"href":"https://zd.atlassian.net/browse/HANG-8"}}
11753          ]}
11754        ]}]}"#;
11755        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11756        let md = adf_to_markdown(&doc).unwrap();
11757        // Should contain both annotation attrs and link syntax
11758        assert!(
11759            md.contains("annotation-id="),
11760            "JFM should contain annotation-id, got: {md}"
11761        );
11762        assert!(
11763            md.contains("](https://"),
11764            "JFM should contain link href, got: {md}"
11765        );
11766        let round_tripped = markdown_to_adf(&md).unwrap();
11767        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11768        let marks = text_node.marks.as_ref().expect("should have marks");
11769        assert!(
11770            marks.iter().any(|m| m.mark_type == "annotation"),
11771            "should have annotation mark, got: {:?}",
11772            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
11773        );
11774        assert!(
11775            marks.iter().any(|m| m.mark_type == "link"),
11776            "should have link mark, got: {:?}",
11777            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
11778        );
11779    }
11780
11781    #[test]
11782    fn annotation_and_code_marks_both_preserved() {
11783        // Issue #508: annotation mark dropped when combined with code mark
11784        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11785          {"type":"text","text":"some text with "},
11786          {"type":"text","text":"annotated code","marks":[
11787            {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"aabbccdd-1234-5678-abcd-000000000001"}},
11788            {"type":"code"}
11789          ]},
11790          {"type":"text","text":" remaining text"}
11791        ]}]}"#;
11792        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11793        let md = adf_to_markdown(&doc).unwrap();
11794        assert!(
11795            md.contains("annotation-id="),
11796            "JFM should contain annotation-id, got: {md}"
11797        );
11798        assert!(
11799            md.contains('`'),
11800            "JFM should contain backticks for code, got: {md}"
11801        );
11802
11803        let round_tripped = markdown_to_adf(&md).unwrap();
11804        let nodes = round_tripped.content[0].content.as_ref().unwrap();
11805        // Find the text node with "annotated code"
11806        let code_node = nodes
11807            .iter()
11808            .find(|n| n.text.as_deref() == Some("annotated code"))
11809            .expect("should have 'annotated code' text node");
11810        let marks = code_node.marks.as_ref().expect("should have marks");
11811        assert!(
11812            marks.iter().any(|m| m.mark_type == "annotation"),
11813            "should have annotation mark, got: {:?}",
11814            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
11815        );
11816        assert!(
11817            marks.iter().any(|m| m.mark_type == "code"),
11818            "should have code mark, got: {:?}",
11819            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
11820        );
11821        let ann = marks.iter().find(|m| m.mark_type == "annotation").unwrap();
11822        let attrs = ann.attrs.as_ref().unwrap();
11823        assert_eq!(attrs["id"], "aabbccdd-1234-5678-abcd-000000000001");
11824        assert_eq!(attrs["annotationType"], "inlineComment");
11825    }
11826
11827    #[test]
11828    fn annotation_and_code_and_link_marks_all_preserved() {
11829        // annotation + code + link should all survive round-trip
11830        let doc = AdfDocument {
11831            version: 1,
11832            doc_type: "doc".to_string(),
11833            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11834                "linked code",
11835                vec![
11836                    AdfMark::annotation("ann-001", "inlineComment"),
11837                    AdfMark::code(),
11838                    AdfMark::link("https://example.com"),
11839                ],
11840            )])],
11841        };
11842        let md = adf_to_markdown(&doc).unwrap();
11843        assert!(
11844            md.contains("annotation-id="),
11845            "JFM should contain annotation-id, got: {md}"
11846        );
11847        assert!(md.contains('`'), "JFM should contain backticks, got: {md}");
11848        assert!(
11849            md.contains("](https://example.com)"),
11850            "JFM should contain link, got: {md}"
11851        );
11852
11853        let round_tripped = markdown_to_adf(&md).unwrap();
11854        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11855        let marks = text_node.marks.as_ref().expect("should have marks");
11856        assert!(
11857            marks.iter().any(|m| m.mark_type == "annotation"),
11858            "should have annotation mark, got: {:?}",
11859            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
11860        );
11861        assert!(
11862            marks.iter().any(|m| m.mark_type == "code"),
11863            "should have code mark, got: {:?}",
11864            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
11865        );
11866        assert!(
11867            marks.iter().any(|m| m.mark_type == "link"),
11868            "should have link mark, got: {:?}",
11869            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
11870        );
11871    }
11872
11873    #[test]
11874    fn multiple_annotations_and_code_mark_preserved() {
11875        // Multiple annotation marks on a code node should all survive
11876        let doc = AdfDocument {
11877            version: 1,
11878            doc_type: "doc".to_string(),
11879            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11880                "doubly annotated",
11881                vec![
11882                    AdfMark::annotation("ann-aaa", "inlineComment"),
11883                    AdfMark::annotation("ann-bbb", "inlineComment"),
11884                    AdfMark::code(),
11885                ],
11886            )])],
11887        };
11888        let md = adf_to_markdown(&doc).unwrap();
11889        assert!(
11890            md.contains("ann-aaa"),
11891            "JFM should contain first annotation id, got: {md}"
11892        );
11893        assert!(
11894            md.contains("ann-bbb"),
11895            "JFM should contain second annotation id, got: {md}"
11896        );
11897
11898        let round_tripped = markdown_to_adf(&md).unwrap();
11899        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11900        let marks = text_node.marks.as_ref().expect("should have marks");
11901        let ann_marks: Vec<_> = marks
11902            .iter()
11903            .filter(|m| m.mark_type == "annotation")
11904            .collect();
11905        assert_eq!(
11906            ann_marks.len(),
11907            2,
11908            "should have 2 annotation marks, got: {}",
11909            ann_marks.len()
11910        );
11911        assert!(
11912            marks.iter().any(|m| m.mark_type == "code"),
11913            "should have code mark"
11914        );
11915    }
11916
11917    #[test]
11918    fn underline_and_link_marks_both_preserved() {
11919        // Underline + link should also coexist
11920        let doc = AdfDocument {
11921            version: 1,
11922            doc_type: "doc".to_string(),
11923            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11924                "click here",
11925                vec![AdfMark::underline(), AdfMark::link("https://example.com")],
11926            )])],
11927        };
11928        let md = adf_to_markdown(&doc).unwrap();
11929        assert!(md.contains("underline"), "should have underline attr: {md}");
11930        assert!(
11931            md.contains("](https://example.com)"),
11932            "should have link: {md}"
11933        );
11934        let round_tripped = markdown_to_adf(&md).unwrap();
11935        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11936        let marks = text_node.marks.as_ref().expect("should have marks");
11937        assert!(marks.iter().any(|m| m.mark_type == "underline"));
11938        assert!(marks.iter().any(|m| m.mark_type == "link"));
11939    }
11940
11941    #[test]
11942    fn annotation_link_and_bold_all_preserved() {
11943        // All three marks should coexist
11944        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11945          {"type":"text","text":"important","marks":[
11946            {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"abc"}},
11947            {"type":"link","attrs":{"href":"https://example.com"}},
11948            {"type":"strong"}
11949          ]}
11950        ]}]}"#;
11951        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11952        let md = adf_to_markdown(&doc).unwrap();
11953        let round_tripped = markdown_to_adf(&md).unwrap();
11954        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11955        let marks = text_node.marks.as_ref().expect("should have marks");
11956        assert!(
11957            marks.iter().any(|m| m.mark_type == "annotation"),
11958            "should have annotation"
11959        );
11960        assert!(
11961            marks.iter().any(|m| m.mark_type == "link"),
11962            "should have link"
11963        );
11964        assert!(
11965            marks.iter().any(|m| m.mark_type == "strong"),
11966            "should have strong"
11967        );
11968    }
11969
11970    #[test]
11971    fn multiple_annotation_marks_round_trip() {
11972        // Issue #439: multiple annotation marks on same text node — all but last dropped
11973        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11974          {"type":"text","text":"some annotated text","marks":[
11975            {"type":"annotation","attrs":{"id":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","annotationType":"inlineComment"}},
11976            {"type":"annotation","attrs":{"id":"ffffffff-1111-2222-3333-444444444444","annotationType":"inlineComment"}}
11977          ]}
11978        ]}]}"#;
11979        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11980
11981        let md = adf_to_markdown(&doc).unwrap();
11982        assert!(
11983            md.contains("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"),
11984            "JFM should contain first annotation id, got: {md}"
11985        );
11986        assert!(
11987            md.contains("ffffffff-1111-2222-3333-444444444444"),
11988            "JFM should contain second annotation id, got: {md}"
11989        );
11990
11991        let round_tripped = markdown_to_adf(&md).unwrap();
11992        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11993        assert_eq!(text_node.text.as_deref(), Some("some annotated text"));
11994        let marks = text_node.marks.as_ref().expect("should have marks");
11995        let annotations: Vec<_> = marks
11996            .iter()
11997            .filter(|m| m.mark_type == "annotation")
11998            .collect();
11999        assert_eq!(
12000            annotations.len(),
12001            2,
12002            "should have 2 annotation marks, got: {annotations:?}"
12003        );
12004        let ids: Vec<_> = annotations
12005            .iter()
12006            .map(|a| a.attrs.as_ref().unwrap()["id"].as_str().unwrap())
12007            .collect();
12008        assert!(ids.contains(&"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"));
12009        assert!(ids.contains(&"ffffffff-1111-2222-3333-444444444444"));
12010    }
12011
12012    #[test]
12013    fn three_annotation_marks_round_trip() {
12014        // Verify three overlapping annotations all survive
12015        let doc = AdfDocument {
12016            version: 1,
12017            doc_type: "doc".to_string(),
12018            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12019                "triple annotated",
12020                vec![
12021                    AdfMark::annotation("id-1", "inlineComment"),
12022                    AdfMark::annotation("id-2", "inlineComment"),
12023                    AdfMark::annotation("id-3", "inlineComment"),
12024                ],
12025            )])],
12026        };
12027        let md = adf_to_markdown(&doc).unwrap();
12028        let round_tripped = markdown_to_adf(&md).unwrap();
12029        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12030        let marks = text_node.marks.as_ref().expect("should have marks");
12031        let annotations: Vec<_> = marks
12032            .iter()
12033            .filter(|m| m.mark_type == "annotation")
12034            .collect();
12035        assert_eq!(
12036            annotations.len(),
12037            3,
12038            "should have 3 annotation marks, got: {annotations:?}"
12039        );
12040    }
12041
12042    #[test]
12043    fn multiple_annotations_with_bold_round_trip() {
12044        // Multiple annotations + bold should all survive
12045        let doc = AdfDocument {
12046            version: 1,
12047            doc_type: "doc".to_string(),
12048            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12049                "bold double annotated",
12050                vec![
12051                    AdfMark::strong(),
12052                    AdfMark::annotation("ann-a", "inlineComment"),
12053                    AdfMark::annotation("ann-b", "inlineComment"),
12054                ],
12055            )])],
12056        };
12057        let md = adf_to_markdown(&doc).unwrap();
12058        let round_tripped = markdown_to_adf(&md).unwrap();
12059        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12060        let marks = text_node.marks.as_ref().expect("should have marks");
12061        assert!(
12062            marks.iter().any(|m| m.mark_type == "strong"),
12063            "should have strong mark"
12064        );
12065        let annotations: Vec<_> = marks
12066            .iter()
12067            .filter(|m| m.mark_type == "annotation")
12068            .collect();
12069        assert_eq!(
12070            annotations.len(),
12071            2,
12072            "should have 2 annotation marks, got: {annotations:?}"
12073        );
12074    }
12075
12076    #[test]
12077    fn multiple_annotations_with_link_round_trip() {
12078        // Multiple annotations + link should all survive
12079        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12080          {"type":"text","text":"linked text","marks":[
12081            {"type":"annotation","attrs":{"id":"ann-x","annotationType":"inlineComment"}},
12082            {"type":"annotation","attrs":{"id":"ann-y","annotationType":"inlineComment"}},
12083            {"type":"link","attrs":{"href":"https://example.com"}}
12084          ]}
12085        ]}]}"#;
12086        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12087        let md = adf_to_markdown(&doc).unwrap();
12088        let round_tripped = markdown_to_adf(&md).unwrap();
12089        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12090        let marks = text_node.marks.as_ref().expect("should have marks");
12091        assert!(
12092            marks.iter().any(|m| m.mark_type == "link"),
12093            "should have link mark"
12094        );
12095        let annotations: Vec<_> = marks
12096            .iter()
12097            .filter(|m| m.mark_type == "annotation")
12098            .collect();
12099        assert_eq!(
12100            annotations.len(),
12101            2,
12102            "should have 2 annotation marks, got: {annotations:?}"
12103        );
12104    }
12105
12106    // ── Issue #471: annotation marks on non-text inline nodes ─────────
12107
12108    #[test]
12109    fn annotation_on_emoji_round_trip() {
12110        // Issue #471: annotation mark on emoji node should survive round-trip
12111        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12112          {"type":"emoji","attrs":{"id":"1f4dd","shortName":":memo:","text":"📝"},"marks":[
12113            {"type":"annotation","attrs":{"id":"ccddee11-2233-4455-aabb-ccddee112233","annotationType":"inlineComment"}}
12114          ]},
12115          {"type":"text","text":" annotated text","marks":[
12116            {"type":"annotation","attrs":{"id":"ccddee11-2233-4455-aabb-ccddee112233","annotationType":"inlineComment"}}
12117          ]}
12118        ]}]}"#;
12119        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12120        let md = adf_to_markdown(&doc).unwrap();
12121        assert!(
12122            md.contains("annotation-id="),
12123            "JFM should contain annotation-id for emoji, got: {md}"
12124        );
12125
12126        let round_tripped = markdown_to_adf(&md).unwrap();
12127        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12128
12129        // Emoji node should retain annotation mark
12130        let emoji_node = nodes.iter().find(|n| n.node_type == "emoji").unwrap();
12131        let emoji_marks = emoji_node.marks.as_ref().expect("emoji should have marks");
12132        assert!(
12133            emoji_marks.iter().any(|m| m.mark_type == "annotation"),
12134            "emoji should have annotation mark, got: {emoji_marks:?}"
12135        );
12136        let ann = emoji_marks
12137            .iter()
12138            .find(|m| m.mark_type == "annotation")
12139            .unwrap();
12140        assert_eq!(
12141            ann.attrs.as_ref().unwrap()["id"],
12142            "ccddee11-2233-4455-aabb-ccddee112233"
12143        );
12144
12145        // Text node should also retain annotation mark
12146        let text_node = nodes.iter().find(|n| n.node_type == "text").unwrap();
12147        let text_marks = text_node.marks.as_ref().expect("text should have marks");
12148        assert!(
12149            text_marks.iter().any(|m| m.mark_type == "annotation"),
12150            "text should have annotation mark"
12151        );
12152    }
12153
12154    #[test]
12155    fn annotation_on_status_round_trip() {
12156        let mut status = AdfNode::status("In Progress", "blue");
12157        status.marks = Some(vec![AdfMark::annotation("ann-status-1", "inlineComment")]);
12158
12159        let doc = AdfDocument {
12160            version: 1,
12161            doc_type: "doc".to_string(),
12162            content: vec![AdfNode::paragraph(vec![status])],
12163        };
12164        let md = adf_to_markdown(&doc).unwrap();
12165        assert!(
12166            md.contains("annotation-id="),
12167            "JFM should contain annotation-id for status, got: {md}"
12168        );
12169
12170        let round_tripped = markdown_to_adf(&md).unwrap();
12171        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12172        let status_node = nodes.iter().find(|n| n.node_type == "status").unwrap();
12173        let marks = status_node
12174            .marks
12175            .as_ref()
12176            .expect("status should have marks");
12177        assert!(
12178            marks.iter().any(|m| m.mark_type == "annotation"),
12179            "status should have annotation mark, got: {marks:?}"
12180        );
12181    }
12182
12183    #[test]
12184    fn annotation_on_date_round_trip() {
12185        let mut date = AdfNode::date("1704067200000");
12186        date.marks = Some(vec![AdfMark::annotation("ann-date-1", "inlineComment")]);
12187
12188        let doc = AdfDocument {
12189            version: 1,
12190            doc_type: "doc".to_string(),
12191            content: vec![AdfNode::paragraph(vec![date])],
12192        };
12193        let md = adf_to_markdown(&doc).unwrap();
12194        assert!(
12195            md.contains("annotation-id="),
12196            "JFM should contain annotation-id for date, got: {md}"
12197        );
12198
12199        let round_tripped = markdown_to_adf(&md).unwrap();
12200        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12201        let date_node = nodes.iter().find(|n| n.node_type == "date").unwrap();
12202        let marks = date_node.marks.as_ref().expect("date should have marks");
12203        assert!(
12204            marks.iter().any(|m| m.mark_type == "annotation"),
12205            "date should have annotation mark, got: {marks:?}"
12206        );
12207    }
12208
12209    #[test]
12210    fn annotation_on_mention_round_trip() {
12211        let mut mention = AdfNode::mention("user-123", "@Alice");
12212        mention.marks = Some(vec![AdfMark::annotation("ann-mention-1", "inlineComment")]);
12213
12214        let doc = AdfDocument {
12215            version: 1,
12216            doc_type: "doc".to_string(),
12217            content: vec![AdfNode::paragraph(vec![mention])],
12218        };
12219        let md = adf_to_markdown(&doc).unwrap();
12220        assert!(
12221            md.contains("annotation-id="),
12222            "JFM should contain annotation-id for mention, got: {md}"
12223        );
12224
12225        let round_tripped = markdown_to_adf(&md).unwrap();
12226        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12227        let mention_node = nodes.iter().find(|n| n.node_type == "mention").unwrap();
12228        let marks = mention_node
12229            .marks
12230            .as_ref()
12231            .expect("mention should have marks");
12232        assert!(
12233            marks.iter().any(|m| m.mark_type == "annotation"),
12234            "mention should have annotation mark, got: {marks:?}"
12235        );
12236    }
12237
12238    #[test]
12239    fn annotation_on_inline_card_round_trip() {
12240        let mut card = AdfNode::inline_card("https://example.com");
12241        card.marks = Some(vec![AdfMark::annotation("ann-card-1", "inlineComment")]);
12242
12243        let doc = AdfDocument {
12244            version: 1,
12245            doc_type: "doc".to_string(),
12246            content: vec![AdfNode::paragraph(vec![card])],
12247        };
12248        let md = adf_to_markdown(&doc).unwrap();
12249        assert!(
12250            md.contains("annotation-id="),
12251            "JFM should contain annotation-id for inlineCard, got: {md}"
12252        );
12253
12254        let round_tripped = markdown_to_adf(&md).unwrap();
12255        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12256        let card_node = nodes.iter().find(|n| n.node_type == "inlineCard").unwrap();
12257        let marks = card_node
12258            .marks
12259            .as_ref()
12260            .expect("inlineCard should have marks");
12261        assert!(
12262            marks.iter().any(|m| m.mark_type == "annotation"),
12263            "inlineCard should have annotation mark, got: {marks:?}"
12264        );
12265    }
12266
12267    #[test]
12268    fn annotation_on_placeholder_round_trip() {
12269        let mut placeholder = AdfNode::placeholder("Enter text here");
12270        placeholder.marks = Some(vec![AdfMark::annotation("ann-ph-1", "inlineComment")]);
12271
12272        let doc = AdfDocument {
12273            version: 1,
12274            doc_type: "doc".to_string(),
12275            content: vec![AdfNode::paragraph(vec![placeholder])],
12276        };
12277        let md = adf_to_markdown(&doc).unwrap();
12278        assert!(
12279            md.contains("annotation-id="),
12280            "JFM should contain annotation-id for placeholder, got: {md}"
12281        );
12282
12283        let round_tripped = markdown_to_adf(&md).unwrap();
12284        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12285        let ph_node = nodes.iter().find(|n| n.node_type == "placeholder").unwrap();
12286        let marks = ph_node
12287            .marks
12288            .as_ref()
12289            .expect("placeholder should have marks");
12290        assert!(
12291            marks.iter().any(|m| m.mark_type == "annotation"),
12292            "placeholder should have annotation mark, got: {marks:?}"
12293        );
12294    }
12295
12296    #[test]
12297    fn multiple_annotations_on_emoji_round_trip() {
12298        // Multiple annotation marks on a single emoji node
12299        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12300          {"type":"emoji","attrs":{"shortName":":fire:","text":"🔥"},"marks":[
12301            {"type":"annotation","attrs":{"id":"ann-1","annotationType":"inlineComment"}},
12302            {"type":"annotation","attrs":{"id":"ann-2","annotationType":"inlineComment"}}
12303          ]}
12304        ]}]}"#;
12305        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12306        let md = adf_to_markdown(&doc).unwrap();
12307
12308        let round_tripped = markdown_to_adf(&md).unwrap();
12309        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12310        let emoji_node = nodes.iter().find(|n| n.node_type == "emoji").unwrap();
12311        let marks = emoji_node.marks.as_ref().expect("emoji should have marks");
12312        let annotations: Vec<_> = marks
12313            .iter()
12314            .filter(|m| m.mark_type == "annotation")
12315            .collect();
12316        assert_eq!(
12317            annotations.len(),
12318            2,
12319            "emoji should have 2 annotation marks, got: {annotations:?}"
12320        );
12321    }
12322
12323    #[test]
12324    fn emoji_without_annotation_unchanged() {
12325        // Ensure emoji nodes without annotation marks are not affected
12326        let doc = AdfDocument {
12327            version: 1,
12328            doc_type: "doc".to_string(),
12329            content: vec![AdfNode::paragraph(vec![AdfNode::emoji(":fire:")])],
12330        };
12331        let md = adf_to_markdown(&doc).unwrap();
12332        // Should NOT have bracketed span wrapping
12333        assert!(
12334            !md.contains('['),
12335            "emoji without annotation should not be wrapped in brackets, got: {md}"
12336        );
12337        assert!(md.contains(":fire:"));
12338    }
12339
12340    // ── Inline directive tests (Tier 4) ───────────────────────────────
12341
12342    #[test]
12343    fn status_directive() {
12344        let doc = markdown_to_adf("The ticket is :status[In Progress]{color=blue}.").unwrap();
12345        let content = doc.content[0].content.as_ref().unwrap();
12346        assert_eq!(content[1].node_type, "status");
12347        assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "In Progress");
12348        assert_eq!(content[1].attrs.as_ref().unwrap()["color"], "blue");
12349    }
12350
12351    #[test]
12352    fn adf_status_to_markdown() {
12353        let doc = AdfDocument {
12354            version: 1,
12355            doc_type: "doc".to_string(),
12356            content: vec![AdfNode::paragraph(vec![AdfNode::status("Done", "green")])],
12357        };
12358        let md = adf_to_markdown(&doc).unwrap();
12359        assert!(md.contains(":status[Done]{color=green}"));
12360    }
12361
12362    #[test]
12363    fn round_trip_status() {
12364        let md = "The ticket is :status[In Progress]{color=blue}.\n";
12365        let doc = markdown_to_adf(md).unwrap();
12366        let result = adf_to_markdown(&doc).unwrap();
12367        assert!(result.contains(":status[In Progress]{color=blue}"));
12368    }
12369
12370    #[test]
12371    fn status_with_style_and_localid_roundtrips() {
12372        let adf = AdfDocument {
12373            version: 1,
12374            doc_type: "doc".to_string(),
12375            content: vec![AdfNode::paragraph(vec![{
12376                let mut node = AdfNode::status("open", "green");
12377                node.attrs.as_mut().unwrap()["style"] =
12378                    serde_json::Value::String("bold".to_string());
12379                node.attrs.as_mut().unwrap()["localId"] =
12380                    serde_json::Value::String("d2205ca5-84b9-4950-a730-bfe550fc146b".to_string());
12381                node
12382            }])],
12383        };
12384
12385        let md = adf_to_markdown(&adf).unwrap();
12386        assert!(
12387            md.contains("style=bold"),
12388            "Markdown should contain style attr: {md}"
12389        );
12390        assert!(
12391            md.contains("localId=d2205ca5"),
12392            "Markdown should contain localId attr: {md}"
12393        );
12394
12395        let rt = markdown_to_adf(&md).unwrap();
12396        let status = &rt.content[0].content.as_ref().unwrap()[0];
12397        let attrs = status.attrs.as_ref().unwrap();
12398        assert_eq!(attrs["text"], "open");
12399        assert_eq!(attrs["color"], "green");
12400        assert_eq!(attrs["style"], "bold");
12401        assert_eq!(
12402            attrs["localId"], "d2205ca5-84b9-4950-a730-bfe550fc146b",
12403            "localId should be preserved, got: {}",
12404            attrs["localId"]
12405        );
12406    }
12407
12408    #[test]
12409    fn status_without_style_still_works() {
12410        let md = ":status[Done]{color=green}\n";
12411        let doc = markdown_to_adf(md).unwrap();
12412        let status = &doc.content[0].content.as_ref().unwrap()[0];
12413        let attrs = status.attrs.as_ref().unwrap();
12414        assert_eq!(attrs["text"], "Done");
12415        assert_eq!(attrs["color"], "green");
12416        // No style attr — should not be present
12417        assert!(
12418            attrs.get("style").is_none() || attrs["style"].is_null(),
12419            "style should not be set when not provided"
12420        );
12421    }
12422
12423    #[test]
12424    fn strip_local_ids_removes_localid_from_status() {
12425        let adf = AdfDocument {
12426            version: 1,
12427            doc_type: "doc".to_string(),
12428            content: vec![AdfNode::paragraph(vec![{
12429                let mut node = AdfNode::status("open", "green");
12430                node.attrs.as_mut().unwrap()["localId"] =
12431                    serde_json::Value::String("real-uuid-here".to_string());
12432                node
12433            }])],
12434        };
12435        let opts = RenderOptions {
12436            strip_local_ids: true,
12437        };
12438        let md = adf_to_markdown_with_options(&adf, &opts).unwrap();
12439        assert!(
12440            !md.contains("localId"),
12441            "localId should be stripped, got: {md}"
12442        );
12443        assert!(md.contains("color=green"), "color should be preserved");
12444    }
12445
12446    #[test]
12447    fn strip_local_ids_removes_localid_from_table() {
12448        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"}]}]}]}]}]}"#;
12449        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12450        let opts = RenderOptions {
12451            strip_local_ids: true,
12452        };
12453        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12454        assert!(
12455            !md.contains("localId"),
12456            "localId should be stripped from table, got: {md}"
12457        );
12458        assert!(md.contains("layout=default"), "layout should be preserved");
12459    }
12460
12461    #[test]
12462    fn default_options_preserve_localid() {
12463        let adf = AdfDocument {
12464            version: 1,
12465            doc_type: "doc".to_string(),
12466            content: vec![AdfNode::paragraph(vec![{
12467                let mut node = AdfNode::status("open", "green");
12468                node.attrs.as_mut().unwrap()["localId"] =
12469                    serde_json::Value::String("real-uuid-here".to_string());
12470                node
12471            }])],
12472        };
12473        let md = adf_to_markdown(&adf).unwrap();
12474        assert!(
12475            md.contains("localId=real-uuid-here"),
12476            "Default should preserve localId, got: {md}"
12477        );
12478    }
12479
12480    #[test]
12481    fn mention_localid_roundtrip() {
12482        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"mention","attrs":{"id":"user123","text":"@Alice","localId":"m-001"}}]}]}"#;
12483        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12484        let md = adf_to_markdown(&doc).unwrap();
12485        assert!(
12486            md.contains("localId=m-001"),
12487            "mention should have localId in md: {md}"
12488        );
12489        let rt = markdown_to_adf(&md).unwrap();
12490        let mention = &rt.content[0].content.as_ref().unwrap()[0];
12491        assert_eq!(mention.attrs.as_ref().unwrap()["localId"], "m-001");
12492    }
12493
12494    #[test]
12495    fn date_localid_roundtrip() {
12496        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
12497        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12498        let md = adf_to_markdown(&doc).unwrap();
12499        assert!(
12500            md.contains("localId=d-001"),
12501            "date should have localId in md: {md}"
12502        );
12503        let rt = markdown_to_adf(&md).unwrap();
12504        let date = &rt.content[0].content.as_ref().unwrap()[0];
12505        assert_eq!(date.attrs.as_ref().unwrap()["localId"], "d-001");
12506    }
12507
12508    #[test]
12509    fn emoji_localid_roundtrip() {
12510        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"emoji","attrs":{"shortName":":smile:","localId":"e-001"}}]}]}"#;
12511        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12512        let md = adf_to_markdown(&doc).unwrap();
12513        assert!(
12514            md.contains("localId=e-001"),
12515            "emoji should have localId in md: {md}"
12516        );
12517        let rt = markdown_to_adf(&md).unwrap();
12518        let emoji = &rt.content[0].content.as_ref().unwrap()[0];
12519        assert_eq!(emoji.attrs.as_ref().unwrap()["localId"], "e-001");
12520    }
12521
12522    #[test]
12523    fn inline_card_localid_roundtrip() {
12524        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"inlineCard","attrs":{"url":"https://example.com","localId":"c-001"}}]}]}"#;
12525        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12526        let md = adf_to_markdown(&doc).unwrap();
12527        assert!(
12528            md.contains("localId=c-001"),
12529            "inlineCard should have localId in md: {md}"
12530        );
12531        let rt = markdown_to_adf(&md).unwrap();
12532        let card = &rt.content[0].content.as_ref().unwrap()[0];
12533        assert_eq!(card.attrs.as_ref().unwrap()["localId"], "c-001");
12534    }
12535
12536    #[test]
12537    fn strip_local_ids_removes_from_mention() {
12538        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"mention","attrs":{"id":"user123","text":"@Alice","localId":"m-001"}}]}]}"#;
12539        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12540        let opts = RenderOptions {
12541            strip_local_ids: true,
12542        };
12543        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12544        assert!(
12545            !md.contains("localId"),
12546            "localId should be stripped from mention: {md}"
12547        );
12548        assert!(md.contains("id=user123"), "other attrs should be preserved");
12549    }
12550
12551    #[test]
12552    fn strip_local_ids_removes_from_date() {
12553        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
12554        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12555        let opts = RenderOptions {
12556            strip_local_ids: true,
12557        };
12558        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12559        assert!(
12560            !md.contains("localId"),
12561            "localId should be stripped from date: {md}"
12562        );
12563    }
12564
12565    #[test]
12566    fn strip_local_ids_removes_from_block_attrs() {
12567        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"p-001"},"content":[{"type":"text","text":"hello"}]}]}"#;
12568        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12569        let opts = RenderOptions {
12570            strip_local_ids: true,
12571        };
12572        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12573        assert!(
12574            !md.contains("localId"),
12575            "localId should be stripped from block attrs: {md}"
12576        );
12577    }
12578
12579    #[test]
12580    fn table_cell_localid_roundtrip() {
12581        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"}]}]}]}]}]}"#;
12582        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12583        let md = adf_to_markdown(&doc).unwrap();
12584        assert!(
12585            md.contains("localId=tc-001"),
12586            "tableCell should have localId in md: {md}"
12587        );
12588        let rt = markdown_to_adf(&md).unwrap();
12589        let cell = &rt.content[0].content.as_ref().unwrap()[0]
12590            .content
12591            .as_ref()
12592            .unwrap()[0];
12593        assert_eq!(
12594            cell.attrs.as_ref().unwrap()["localId"],
12595            "tc-001",
12596            "tableCell localId should round-trip"
12597        );
12598    }
12599
12600    #[test]
12601    fn table_cell_border_mark_roundtrip() {
12602        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"}]}]}]}]}]}"##;
12603        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12604        let md = adf_to_markdown(&doc).unwrap();
12605        assert!(
12606            md.contains("border-color=#ff000033"),
12607            "tableCell should have border-color in md: {md}"
12608        );
12609        assert!(
12610            md.contains("border-size=2"),
12611            "tableCell should have border-size in md: {md}"
12612        );
12613        let rt = markdown_to_adf(&md).unwrap();
12614        let cell = &rt.content[0].content.as_ref().unwrap()[0]
12615            .content
12616            .as_ref()
12617            .unwrap()[0];
12618        let marks = cell.marks.as_ref().expect("tableCell should have marks");
12619        assert_eq!(marks.len(), 1);
12620        assert_eq!(marks[0].mark_type, "border");
12621        let attrs = marks[0].attrs.as_ref().unwrap();
12622        assert_eq!(attrs["color"], "#ff000033");
12623        assert_eq!(attrs["size"], 2);
12624    }
12625
12626    #[test]
12627    fn table_header_border_mark_roundtrip() {
12628        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"}]}]}]}]}]}"##;
12629        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12630        let md = adf_to_markdown(&doc).unwrap();
12631        assert!(md.contains("border-color=#0000ff"), "md: {md}");
12632        assert!(md.contains("border-size=3"), "md: {md}");
12633        let rt = markdown_to_adf(&md).unwrap();
12634        let cell = &rt.content[0].content.as_ref().unwrap()[0]
12635            .content
12636            .as_ref()
12637            .unwrap()[0];
12638        assert_eq!(cell.node_type, "tableHeader");
12639        let marks = cell.marks.as_ref().expect("tableHeader should have marks");
12640        assert_eq!(marks[0].mark_type, "border");
12641        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#0000ff");
12642        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
12643    }
12644
12645    #[test]
12646    fn table_cell_border_mark_with_attrs_roundtrip() {
12647        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"}]}]}]}]}]}"##;
12648        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12649        let md = adf_to_markdown(&doc).unwrap();
12650        assert!(md.contains("bg=#e6fcff"), "md: {md}");
12651        assert!(md.contains("colspan=2"), "md: {md}");
12652        assert!(md.contains("border-color=#ff000033"), "md: {md}");
12653        let rt = markdown_to_adf(&md).unwrap();
12654        let cell = &rt.content[0].content.as_ref().unwrap()[0]
12655            .content
12656            .as_ref()
12657            .unwrap()[0];
12658        assert_eq!(cell.attrs.as_ref().unwrap()["background"], "#e6fcff");
12659        assert_eq!(cell.attrs.as_ref().unwrap()["colspan"], 2);
12660        let marks = cell.marks.as_ref().expect("should have marks");
12661        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff000033");
12662    }
12663
12664    #[test]
12665    fn table_cell_no_border_mark_unchanged() {
12666        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"}]}]}]}]}]}"#;
12667        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12668        let md = adf_to_markdown(&doc).unwrap();
12669        assert!(
12670            !md.contains("border-color"),
12671            "no border attrs expected: {md}"
12672        );
12673        let rt = markdown_to_adf(&md).unwrap();
12674        let cell = &rt.content[0].content.as_ref().unwrap()[0]
12675            .content
12676            .as_ref()
12677            .unwrap()[0];
12678        assert!(cell.marks.is_none(), "no marks expected on plain cell");
12679    }
12680
12681    #[test]
12682    fn table_cell_border_size_only_defaults_color() {
12683        // border-size without border-color should still produce a border mark
12684        // with the default color
12685        let md = "::::table\n:::tr\n:::td{border-size=3}\ncell\n:::\n:::\n::::\n";
12686        let doc = markdown_to_adf(md).unwrap();
12687        let cell = &doc.content[0].content.as_ref().unwrap()[0]
12688            .content
12689            .as_ref()
12690            .unwrap()[0];
12691        let marks = cell.marks.as_ref().expect("should have border mark");
12692        assert_eq!(marks[0].mark_type, "border");
12693        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#000000");
12694        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
12695    }
12696
12697    #[test]
12698    fn table_cell_border_color_only_defaults_size() {
12699        // border-color without border-size should default size to 1
12700        let md = "::::table\n:::tr\n:::td{border-color=#ff0000}\ncell\n:::\n:::\n::::\n";
12701        let doc = markdown_to_adf(md).unwrap();
12702        let cell = &doc.content[0].content.as_ref().unwrap()[0]
12703            .content
12704            .as_ref()
12705            .unwrap()[0];
12706        let marks = cell.marks.as_ref().expect("should have border mark");
12707        assert_eq!(marks[0].mark_type, "border");
12708        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff0000");
12709        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 1);
12710    }
12711
12712    #[test]
12713    fn media_file_border_mark_roundtrip() {
12714        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}}]}]}]}"##;
12715        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12716        let md = adf_to_markdown(&doc).unwrap();
12717        assert!(
12718            md.contains("border-color=#091e4224"),
12719            "media should have border-color in md: {md}"
12720        );
12721        assert!(
12722            md.contains("border-size=2"),
12723            "media should have border-size in md: {md}"
12724        );
12725        let rt = markdown_to_adf(&md).unwrap();
12726        let media_single = &rt.content[0];
12727        let media = &media_single.content.as_ref().unwrap()[0];
12728        assert_eq!(media.node_type, "media");
12729        let marks = media.marks.as_ref().expect("media should have marks");
12730        assert_eq!(marks.len(), 1);
12731        assert_eq!(marks[0].mark_type, "border");
12732        let attrs = marks[0].attrs.as_ref().unwrap();
12733        assert_eq!(attrs["color"], "#091e4224");
12734        assert_eq!(attrs["size"], 2);
12735    }
12736
12737    #[test]
12738    fn media_external_border_mark_roundtrip() {
12739        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}}]}]}]}"##;
12740        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12741        let md = adf_to_markdown(&doc).unwrap();
12742        assert!(
12743            md.contains("border-color=#ff0000"),
12744            "external media should have border-color in md: {md}"
12745        );
12746        assert!(
12747            md.contains("border-size=3"),
12748            "external media should have border-size in md: {md}"
12749        );
12750        let rt = markdown_to_adf(&md).unwrap();
12751        let media = &rt.content[0].content.as_ref().unwrap()[0];
12752        let marks = media.marks.as_ref().expect("media should have marks");
12753        assert_eq!(marks[0].mark_type, "border");
12754        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff0000");
12755        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
12756    }
12757
12758    #[test]
12759    fn media_file_no_border_mark_unchanged() {
12760        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}}]}]}"#;
12761        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12762        let md = adf_to_markdown(&doc).unwrap();
12763        assert!(
12764            !md.contains("border-color"),
12765            "no border attrs expected: {md}"
12766        );
12767        let rt = markdown_to_adf(&md).unwrap();
12768        let media = &rt.content[0].content.as_ref().unwrap()[0];
12769        assert!(media.marks.is_none(), "no marks expected on plain media");
12770    }
12771
12772    #[test]
12773    fn media_border_size_only_defaults_color() {
12774        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}}]}]}]}"##;
12775        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12776        let md = adf_to_markdown(&doc).unwrap();
12777        assert!(md.contains("border-size=4"), "md: {md}");
12778        let rt = markdown_to_adf(&md).unwrap();
12779        let media = &rt.content[0].content.as_ref().unwrap()[0];
12780        let marks = media.marks.as_ref().expect("should have border mark");
12781        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#000000");
12782        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 4);
12783    }
12784
12785    #[test]
12786    fn media_border_color_only_defaults_size() {
12787        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"}}]}]}]}"##;
12788        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12789        let md = adf_to_markdown(&doc).unwrap();
12790        assert!(md.contains("border-color=#00ff00"), "md: {md}");
12791        let rt = markdown_to_adf(&md).unwrap();
12792        let media = &rt.content[0].content.as_ref().unwrap()[0];
12793        let marks = media.marks.as_ref().expect("should have border mark");
12794        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#00ff00");
12795        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 1);
12796    }
12797
12798    #[test]
12799    fn media_border_with_other_attrs_roundtrip() {
12800        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}}]}]}]}"##;
12801        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12802        let md = adf_to_markdown(&doc).unwrap();
12803        assert!(md.contains("layout=wide"), "md: {md}");
12804        assert!(md.contains("mediaWidth=600"), "md: {md}");
12805        assert!(md.contains("border-color=#091e4224"), "md: {md}");
12806        assert!(md.contains("border-size=2"), "md: {md}");
12807        let rt = markdown_to_adf(&md).unwrap();
12808        let ms = &rt.content[0];
12809        assert_eq!(ms.attrs.as_ref().unwrap()["layout"], "wide");
12810        let media = &ms.content.as_ref().unwrap()[0];
12811        let marks = media.marks.as_ref().expect("should have marks");
12812        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#091e4224");
12813        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 2);
12814    }
12815
12816    #[test]
12817    fn table_row_localid_roundtrip() {
12818        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"}]}]}]}]}]}"#;
12819        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12820        let md = adf_to_markdown(&doc).unwrap();
12821        assert!(
12822            md.contains("localId=tr-001"),
12823            "tableRow should have localId in md: {md}"
12824        );
12825        let rt = markdown_to_adf(&md).unwrap();
12826        let row = &rt.content[0].content.as_ref().unwrap()[0];
12827        assert_eq!(
12828            row.attrs.as_ref().unwrap()["localId"],
12829            "tr-001",
12830            "tableRow localId should round-trip"
12831        );
12832    }
12833
12834    #[test]
12835    fn list_item_localid_roundtrip() {
12836        // listItem localId is emitted as trailing inline attrs and parsed back
12837        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"}]}]}]}]}"#;
12838        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12839        let md = adf_to_markdown(&doc).unwrap();
12840        assert!(
12841            md.contains("localId=li-001"),
12842            "listItem should have localId in md: {md}"
12843        );
12844        // Verify localId is on the listItem, NOT promoted to bulletList
12845        let rt = markdown_to_adf(&md).unwrap();
12846        let list = &rt.content[0];
12847        assert!(
12848            list.attrs.is_none() || list.attrs.as_ref().unwrap().get("localId").is_none(),
12849            "bulletList should NOT have localId: {:?}",
12850            list.attrs
12851        );
12852        let item = &list.content.as_ref().unwrap()[0];
12853        assert_eq!(
12854            item.attrs.as_ref().unwrap()["localId"],
12855            "li-001",
12856            "listItem should have localId=li-001"
12857        );
12858    }
12859
12860    #[test]
12861    fn list_item_localid_not_promoted_to_parent() {
12862        // Verify localId stays on listItem and doesn't leak to parent list
12863        let md = "- item {localId=li-002}\n";
12864        let doc = markdown_to_adf(md).unwrap();
12865        let list = &doc.content[0];
12866        assert!(
12867            list.attrs.is_none(),
12868            "bulletList should have no attrs: {:?}",
12869            list.attrs
12870        );
12871        let item = &list.content.as_ref().unwrap()[0];
12872        assert_eq!(item.attrs.as_ref().unwrap()["localId"], "li-002");
12873    }
12874
12875    #[test]
12876    fn ordered_list_item_localid_roundtrip() {
12877        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"}]}]}]}]}"#;
12878        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12879        let md = adf_to_markdown(&doc).unwrap();
12880        assert!(md.contains("localId=oli-001"), "md: {md}");
12881        let rt = markdown_to_adf(&md).unwrap();
12882        let item = &rt.content[0].content.as_ref().unwrap()[0];
12883        assert_eq!(item.attrs.as_ref().unwrap()["localId"], "oli-001");
12884    }
12885
12886    #[test]
12887    fn task_item_localid_roundtrip() {
12888        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"}]}]}]}]}"#;
12889        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12890        let md = adf_to_markdown(&doc).unwrap();
12891        assert!(md.contains("localId=ti-001"), "md: {md}");
12892        let rt = markdown_to_adf(&md).unwrap();
12893        let item = &rt.content[0].content.as_ref().unwrap()[0];
12894        assert_eq!(item.attrs.as_ref().unwrap()["localId"], "ti-001");
12895    }
12896
12897    /// Issue #447: taskList with empty-string localId and taskItems with
12898    /// short numeric localIds must survive a full round-trip.
12899    #[test]
12900    fn task_list_short_localid_roundtrip() {
12901        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"}]}]}]}"#;
12902        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12903        let md = adf_to_markdown(&doc).unwrap();
12904        // Both taskItem localIds should appear in the markdown
12905        assert!(md.contains("localId=42"), "localId=42 missing: {md}");
12906        assert!(md.contains("localId=99"), "localId=99 missing: {md}");
12907        // Empty-string localId should NOT appear as {localId=}
12908        assert!(
12909            !md.contains("localId=}"),
12910            "empty localId should not be emitted: {md}"
12911        );
12912        let rt = markdown_to_adf(&md).unwrap();
12913        let task_list = &rt.content[0];
12914        assert_eq!(task_list.node_type, "taskList");
12915        // No spurious extra nodes from {localId=}
12916        assert_eq!(rt.content.len(), 1, "should be exactly one top-level node");
12917        let items = task_list.content.as_ref().unwrap();
12918        assert_eq!(items.len(), 2);
12919        // First taskItem: localId=42, state=TODO, no content
12920        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
12921        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
12922        assert!(
12923            items[0].content.is_none(),
12924            "empty taskItem should have no content: {:?}",
12925            items[0].content
12926        );
12927        // Second taskItem: localId=99, state=DONE, content with text
12928        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "99");
12929        assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
12930        let content = items[1].content.as_ref().unwrap();
12931        assert_eq!(content.len(), 1);
12932        assert_eq!(content[0].text.as_deref(), Some("done task"));
12933    }
12934
12935    /// Issue #507: numeric localId on taskItem with hardBreak must survive
12936    /// round-trip — the {localId=…} suffix lands on the continuation line
12937    /// and must still be extracted by the parser.
12938    #[test]
12939    fn task_item_numeric_localid_with_hardbreak_roundtrip() {
12940        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!!)"}]}]}]}]}"#;
12941        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12942        let md = adf_to_markdown(&doc).unwrap();
12943        // localId must appear in the markdown output
12944        assert!(md.contains("localId=42"), "localId=42 missing: {md}");
12945        // Round-trip back to ADF
12946        let rt = markdown_to_adf(&md).unwrap();
12947        assert_eq!(rt.content.len(), 1, "exactly one top-level node");
12948        let task_list = &rt.content[0];
12949        assert_eq!(task_list.node_type, "taskList");
12950        let items = task_list.content.as_ref().unwrap();
12951        assert_eq!(items.len(), 1);
12952        // localId preserved
12953        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
12954        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DONE");
12955        // Content structure preserved: paragraph with link + hardBreak + text
12956        let para = &items[0].content.as_ref().unwrap()[0];
12957        assert_eq!(para.node_type, "paragraph");
12958        let inlines = para.content.as_ref().unwrap();
12959        assert_eq!(inlines[0].node_type, "text");
12960        assert_eq!(
12961            inlines[0].text.as_deref(),
12962            Some("Engineering Onboarding Link")
12963        );
12964        assert_eq!(inlines[1].node_type, "hardBreak");
12965        assert_eq!(inlines[2].node_type, "text");
12966        assert_eq!(
12967            inlines[2].text.as_deref(),
12968            Some("(This has links to all the various useful tools!!)")
12969        );
12970        // The {localId=…} must not appear as literal text in the ADF output
12971        let rt_json = serde_json::to_string(&rt).unwrap();
12972        assert!(
12973            !rt_json.contains("{localId="),
12974            "localId attr syntax should not leak into ADF text: {rt_json}"
12975        );
12976    }
12977
12978    /// Issue #507: multiple taskItems with hardBreaks and numeric localIds.
12979    #[test]
12980    fn task_item_multiple_hardbreak_localids_roundtrip() {
12981        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"}]}]}]}]}"#;
12982        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12983        let md = adf_to_markdown(&doc).unwrap();
12984        assert!(md.contains("localId=42"), "localId=42 missing: {md}");
12985        assert!(md.contains("localId=67"), "localId=67 missing: {md}");
12986        let rt = markdown_to_adf(&md).unwrap();
12987        let items = rt.content[0].content.as_ref().unwrap();
12988        assert_eq!(items.len(), 2);
12989        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
12990        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "67");
12991        // Verify hardBreak content structure for both items
12992        for item in items {
12993            let para = &item.content.as_ref().unwrap()[0];
12994            assert_eq!(para.node_type, "paragraph");
12995            let inlines = para.content.as_ref().unwrap();
12996            assert_eq!(inlines[1].node_type, "hardBreak");
12997        }
12998    }
12999
13000    /// Issue #521: sibling taskItems with numeric localIds and hardBreak —
13001    /// unwrapped inline content.  The hardBreak continuation line must be
13002    /// indented so it stays within the list item, and both localIds must
13003    /// survive the round-trip.
13004    #[test]
13005    fn task_item_sibling_localid_hardbreak_unwrapped_roundtrip() {
13006        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"}]}]}]}"#;
13007        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13008        let md = adf_to_markdown(&doc).unwrap();
13009        // Continuation line must be indented
13010        assert!(
13011            md.contains("  (parenthetical"),
13012            "continuation line should be 2-space indented: {md}"
13013        );
13014        assert!(md.contains("localId=42"), "localId=42 missing: {md}");
13015        assert!(md.contains("localId=69"), "localId=69 missing: {md}");
13016        let rt = markdown_to_adf(&md).unwrap();
13017        // Must remain a single taskList with 2 items
13018        assert_eq!(
13019            rt.content.len(),
13020            1,
13021            "should be one taskList: {:#?}",
13022            rt.content
13023        );
13024        assert_eq!(rt.content[0].node_type, "taskList");
13025        let items = rt.content[0].content.as_ref().unwrap();
13026        assert_eq!(items.len(), 2, "should have 2 taskItems");
13027        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13028        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "69");
13029        // Verify first item has hardBreak
13030        let first_content = items[0].content.as_ref().unwrap();
13031        assert!(
13032            first_content.iter().any(|n| n.node_type == "hardBreak"),
13033            "first item should contain hardBreak"
13034        );
13035        // Verify second item content
13036        let second_content = items[1].content.as_ref().unwrap();
13037        assert_eq!(second_content[0].node_type, "text");
13038        assert_eq!(
13039            second_content[0].text.as_deref().unwrap(),
13040            "second task item"
13041        );
13042    }
13043
13044    /// Issue #521: sibling taskItems with paragraph-wrapped content and
13045    /// hardBreak — localIds must not be swapped or lost.
13046    #[test]
13047    fn task_item_sibling_localid_hardbreak_paragraph_roundtrip() {
13048        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"}]}]}]}]}"#;
13049        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13050        let md = adf_to_markdown(&doc).unwrap();
13051        let rt = markdown_to_adf(&md).unwrap();
13052        assert_eq!(
13053            rt.content.len(),
13054            1,
13055            "should be one taskList: {:#?}",
13056            rt.content
13057        );
13058        let items = rt.content[0].content.as_ref().unwrap();
13059        assert_eq!(items.len(), 2);
13060        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13061        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "69");
13062    }
13063
13064    /// Issue #521: three sibling taskItems — the middle one has a hardBreak.
13065    /// Ensures localIds don't leak between adjacent items.
13066    #[test]
13067    fn task_item_three_siblings_middle_hardbreak_roundtrip() {
13068        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"}]}]}]}"#;
13069        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13070        let md = adf_to_markdown(&doc).unwrap();
13071        let rt = markdown_to_adf(&md).unwrap();
13072        assert_eq!(rt.content.len(), 1);
13073        let items = rt.content[0].content.as_ref().unwrap();
13074        assert_eq!(items.len(), 3);
13075        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "10");
13076        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "20");
13077        assert_eq!(items[2].attrs.as_ref().unwrap()["localId"], "30");
13078        // Middle item should have hardBreak
13079        let mid_content = items[1].content.as_ref().unwrap();
13080        assert!(mid_content.iter().any(|n| n.node_type == "hardBreak"));
13081    }
13082
13083    /// Issue #447: regression — taskList with empty localId must not inject
13084    /// a spurious paragraph.
13085    #[test]
13086    fn task_list_empty_localid_no_spurious_paragraph() {
13087        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"}]}]}]}"#;
13088        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13089        let md = adf_to_markdown(&doc).unwrap();
13090        assert!(
13091            !md.contains("{localId=}"),
13092            "empty localId should not be emitted: {md}"
13093        );
13094        let rt = markdown_to_adf(&md).unwrap();
13095        assert_eq!(
13096            rt.content.len(),
13097            1,
13098            "no spurious paragraph: {:#?}",
13099            rt.content
13100        );
13101        assert_eq!(rt.content[0].node_type, "taskList");
13102    }
13103
13104    /// Issue #447: taskList localId should be stripped when strip_local_ids is set.
13105    #[test]
13106    fn task_list_localid_stripped() {
13107        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"}]}]}]}]}"#;
13108        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13109        let opts = RenderOptions {
13110            strip_local_ids: true,
13111        };
13112        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13113        assert!(!md.contains("localId"), "localId should be stripped: {md}");
13114    }
13115
13116    /// Issue #447: taskItem with no content still emits localId.
13117    #[test]
13118    fn task_item_no_content_emits_localid() {
13119        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"}}]}]}"#;
13120        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13121        let md = adf_to_markdown(&doc).unwrap();
13122        assert!(
13123            md.contains("localId=abc"),
13124            "localId should be emitted even without content: {md}"
13125        );
13126        let rt = markdown_to_adf(&md).unwrap();
13127        let item = &rt.content[0].content.as_ref().unwrap()[0];
13128        assert_eq!(item.attrs.as_ref().unwrap()["localId"], "abc");
13129        assert!(item.content.is_none(), "should have no content");
13130    }
13131
13132    /// Issue #447: taskList localId roundtrips through block attrs.
13133    #[test]
13134    fn task_list_localid_roundtrip() {
13135        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"}]}]}]}]}"#;
13136        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13137        let md = adf_to_markdown(&doc).unwrap();
13138        assert!(
13139            md.contains("localId=tl-xyz"),
13140            "taskList localId missing: {md}"
13141        );
13142        let rt = markdown_to_adf(&md).unwrap();
13143        assert_eq!(
13144            rt.content[0].attrs.as_ref().unwrap()["localId"],
13145            "tl-xyz",
13146            "taskList localId should survive round-trip"
13147        );
13148    }
13149
13150    /// Issue #478: taskItem with paragraph wrapper (no localId) preserves wrapper on round-trip.
13151    #[test]
13152    fn task_item_paragraph_wrapper_roundtrip_no_localid() {
13153        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"}]}]}]}]}"#;
13154        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13155        let md = adf_to_markdown(&doc).unwrap();
13156        assert!(
13157            md.contains("paraLocalId=_"),
13158            "should emit paraLocalId=_ sentinel: {md}"
13159        );
13160        let rt = markdown_to_adf(&md).unwrap();
13161        let item = &rt.content[0].content.as_ref().unwrap()[0];
13162        let content = item.content.as_ref().unwrap();
13163        assert_eq!(content.len(), 1, "should have one child: {content:#?}");
13164        assert_eq!(
13165            content[0].node_type, "paragraph",
13166            "child should be a paragraph: {content:#?}"
13167        );
13168        let para_content = content[0].content.as_ref().unwrap();
13169        assert_eq!(
13170            para_content[0].text.as_deref(),
13171            Some("A task with paragraph wrapper")
13172        );
13173        // Paragraph should have no attrs (localId was absent in the original)
13174        assert!(
13175            content[0].attrs.is_none(),
13176            "paragraph should have no attrs: {:?}",
13177            content[0].attrs
13178        );
13179    }
13180
13181    /// Issue #478: taskItem with paragraph wrapper AND paraLocalId preserves both.
13182    #[test]
13183    fn task_item_paragraph_wrapper_roundtrip_with_localid() {
13184        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"}]}]}]}]}"#;
13185        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13186        let md = adf_to_markdown(&doc).unwrap();
13187        assert!(
13188            md.contains("paraLocalId=p-001"),
13189            "should emit paraLocalId=p-001: {md}"
13190        );
13191        let rt = markdown_to_adf(&md).unwrap();
13192        let item = &rt.content[0].content.as_ref().unwrap()[0];
13193        let content = item.content.as_ref().unwrap();
13194        assert_eq!(content[0].node_type, "paragraph");
13195        assert_eq!(
13196            content[0].attrs.as_ref().unwrap()["localId"],
13197            "p-001",
13198            "paragraph localId should be preserved"
13199        );
13200    }
13201
13202    /// Issue #478: taskItem WITHOUT paragraph wrapper (unwrapped inline) still round-trips correctly.
13203    #[test]
13204    fn task_item_unwrapped_inline_no_paragraph_on_roundtrip() {
13205        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"}]}]}]}"#;
13206        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13207        let md = adf_to_markdown(&doc).unwrap();
13208        assert!(
13209            !md.contains("paraLocalId"),
13210            "should NOT emit paraLocalId for unwrapped inline: {md}"
13211        );
13212        let rt = markdown_to_adf(&md).unwrap();
13213        let item = &rt.content[0].content.as_ref().unwrap()[0];
13214        let content = item.content.as_ref().unwrap();
13215        assert_eq!(
13216            content[0].node_type, "text",
13217            "should remain unwrapped: {content:#?}"
13218        );
13219    }
13220
13221    /// Issue #478: DONE taskItem with paragraph wrapper round-trips.
13222    #[test]
13223    fn task_item_done_paragraph_wrapper_roundtrip() {
13224        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"}]}]}]}]}"#;
13225        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13226        let md = adf_to_markdown(&doc).unwrap();
13227        assert!(md.contains("- [x]"), "should render as done: {md}");
13228        let rt = markdown_to_adf(&md).unwrap();
13229        let item = &rt.content[0].content.as_ref().unwrap()[0];
13230        assert_eq!(item.attrs.as_ref().unwrap()["state"], "DONE");
13231        let content = item.content.as_ref().unwrap();
13232        assert_eq!(content[0].node_type, "paragraph");
13233    }
13234
13235    /// Issue #478: mixed taskItems — some with paragraph wrapper, some without.
13236    #[test]
13237    fn task_item_mixed_paragraph_and_unwrapped_roundtrip() {
13238        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"}]}]}]}"#;
13239        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13240        let md = adf_to_markdown(&doc).unwrap();
13241        let rt = markdown_to_adf(&md).unwrap();
13242        let items = rt.content[0].content.as_ref().unwrap();
13243        assert_eq!(items.len(), 2);
13244        // First item: paragraph wrapper preserved
13245        let c1 = items[0].content.as_ref().unwrap();
13246        assert_eq!(
13247            c1[0].node_type, "paragraph",
13248            "first item should have paragraph wrapper"
13249        );
13250        // Second item: no paragraph wrapper
13251        let c2 = items[1].content.as_ref().unwrap();
13252        assert_eq!(
13253            c2[0].node_type, "text",
13254            "second item should remain unwrapped"
13255        );
13256    }
13257
13258    /// Issue #478: taskItem with paragraph wrapper containing marks round-trips.
13259    #[test]
13260    fn task_item_paragraph_wrapper_with_marks_roundtrip() {
13261        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"}]}]}]}]}]}"#;
13262        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13263        let md = adf_to_markdown(&doc).unwrap();
13264        let rt = markdown_to_adf(&md).unwrap();
13265        let item = &rt.content[0].content.as_ref().unwrap()[0];
13266        let content = item.content.as_ref().unwrap();
13267        assert_eq!(content[0].node_type, "paragraph");
13268        let para_children = content[0].content.as_ref().unwrap();
13269        assert!(
13270            para_children.len() >= 2,
13271            "paragraph should contain multiple inline nodes"
13272        );
13273    }
13274
13275    /// Issue #478: strip_local_ids suppresses the paraLocalId=_ sentinel too.
13276    #[test]
13277    fn task_item_paragraph_wrapper_stripped_with_option() {
13278        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"}]}]}]}]}"#;
13279        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13280        let opts = RenderOptions {
13281            strip_local_ids: true,
13282        };
13283        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13284        assert!(
13285            !md.contains("paraLocalId"),
13286            "paraLocalId should be stripped: {md}"
13287        );
13288        assert!(
13289            !md.contains("localId"),
13290            "all localIds should be stripped: {md}"
13291        );
13292    }
13293
13294    #[test]
13295    fn trailing_space_preserved_with_hex_localid() {
13296        // Issue #449: trailing whitespace stripped from text node
13297        // when listItem has a hex-format localId (no hyphens)
13298        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 "}]}]}]}]}"#;
13299        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13300        let md = adf_to_markdown(&doc).unwrap();
13301        let rt = markdown_to_adf(&md).unwrap();
13302        let item = &rt.content[0].content.as_ref().unwrap()[0];
13303        assert_eq!(
13304            item.attrs.as_ref().unwrap()["localId"],
13305            "aabb112233cc",
13306            "localId should round-trip"
13307        );
13308        let para = &item.content.as_ref().unwrap()[0];
13309        let inlines = para.content.as_ref().unwrap();
13310        let last = inlines.last().unwrap();
13311        assert!(
13312            last.text.as_deref().unwrap_or("").ends_with(' '),
13313            "trailing space should be preserved, got nodes: {:?}",
13314            inlines
13315                .iter()
13316                .map(|n| (&n.node_type, &n.text))
13317                .collect::<Vec<_>>()
13318        );
13319    }
13320
13321    #[test]
13322    fn extract_trailing_local_id_preserves_trailing_space() {
13323        // Issue #449: only strip the single separator space before {localId=...}
13324        let (before, lid, _) = extract_trailing_local_id("trailing space  {localId=aabb112233cc}");
13325        assert_eq!(before, "trailing space ");
13326        assert_eq!(lid.as_deref(), Some("aabb112233cc"));
13327    }
13328
13329    #[test]
13330    fn extract_trailing_local_id_no_trailing_space() {
13331        let (before, lid, _) = extract_trailing_local_id("text {localId=abc123}");
13332        assert_eq!(before, "text");
13333        assert_eq!(lid.as_deref(), Some("abc123"));
13334    }
13335
13336    #[test]
13337    fn extract_trailing_local_id_no_attrs() {
13338        let (before, lid, pid) = extract_trailing_local_id("plain text");
13339        assert_eq!(before, "plain text");
13340        assert!(lid.is_none());
13341        assert!(pid.is_none());
13342    }
13343
13344    #[test]
13345    fn list_item_localid_stripped() {
13346        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"}]}]}]}]}"#;
13347        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13348        let opts = RenderOptions {
13349            strip_local_ids: true,
13350        };
13351        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13352        assert!(!md.contains("localId"), "localId should be stripped: {md}");
13353    }
13354
13355    #[test]
13356    fn paragraph_localid_in_list_item_roundtrip() {
13357        // Issue #417: paragraph.attrs.localId dropped in listItem context
13358        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"}]}]}]}]}"#;
13359        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13360        let md = adf_to_markdown(&doc).unwrap();
13361        assert!(
13362            md.contains("paraLocalId=para-001"),
13363            "paragraph localId should be in md: {md}"
13364        );
13365        let rt = markdown_to_adf(&md).unwrap();
13366        let item = &rt.content[0].content.as_ref().unwrap()[0];
13367        assert_eq!(
13368            item.attrs.as_ref().unwrap()["localId"],
13369            "item-001",
13370            "listItem localId should survive"
13371        );
13372        let para = &item.content.as_ref().unwrap()[0];
13373        assert_eq!(
13374            para.attrs.as_ref().unwrap()["localId"],
13375            "para-001",
13376            "paragraph localId should survive round-trip"
13377        );
13378    }
13379
13380    #[test]
13381    fn paragraph_localid_in_ordered_list_item_roundtrip() {
13382        // Issue #417: paragraph localId in ordered list
13383        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"}]}]}]}]}"#;
13384        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13385        let md = adf_to_markdown(&doc).unwrap();
13386        assert!(md.contains("paraLocalId=op-001"), "md: {md}");
13387        let rt = markdown_to_adf(&md).unwrap();
13388        let item = &rt.content[0].content.as_ref().unwrap()[0];
13389        assert_eq!(item.attrs.as_ref().unwrap()["localId"], "oli-001");
13390        let para = &item.content.as_ref().unwrap()[0];
13391        assert_eq!(para.attrs.as_ref().unwrap()["localId"], "op-001");
13392    }
13393
13394    #[test]
13395    fn paragraph_localid_only_in_list_item() {
13396        // paragraph has localId but listItem does not
13397        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"}]}]}]}]}"#;
13398        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13399        let md = adf_to_markdown(&doc).unwrap();
13400        assert!(
13401            md.contains("paraLocalId=para-only"),
13402            "paragraph localId should be emitted: {md}"
13403        );
13404        let rt = markdown_to_adf(&md).unwrap();
13405        let item = &rt.content[0].content.as_ref().unwrap()[0];
13406        assert!(item.attrs.is_none(), "listItem should have no attrs");
13407        let para = &item.content.as_ref().unwrap()[0];
13408        assert_eq!(para.attrs.as_ref().unwrap()["localId"], "para-only");
13409    }
13410
13411    #[test]
13412    fn paragraph_localid_in_table_header_roundtrip() {
13413        // Issue #417: paragraph.attrs.localId dropped in tableHeader context
13414        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"}]}]}]}]}]}"#;
13415        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13416        let md = adf_to_markdown(&doc).unwrap();
13417        // Should use directive form (not pipe table) to preserve paragraph localId
13418        assert!(
13419            md.contains("localId=aaaa-aaaa"),
13420            "paragraph localId should be in md: {md}"
13421        );
13422        let rt = markdown_to_adf(&md).unwrap();
13423        let cell = &rt.content[0].content.as_ref().unwrap()[0]
13424            .content
13425            .as_ref()
13426            .unwrap()[0];
13427        let para = &cell.content.as_ref().unwrap()[0];
13428        assert_eq!(
13429            para.attrs.as_ref().unwrap()["localId"],
13430            "aaaa-aaaa",
13431            "paragraph localId should survive round-trip in tableHeader"
13432        );
13433    }
13434
13435    #[test]
13436    fn paragraph_localid_in_table_cell_roundtrip() {
13437        // Issue #417: paragraph localId in tableCell forces directive table
13438        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"}]}]}]}]}]}"#;
13439        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13440        let md = adf_to_markdown(&doc).unwrap();
13441        assert!(
13442            md.contains("localId=cell-para"),
13443            "paragraph localId should be in md: {md}"
13444        );
13445        let rt = markdown_to_adf(&md).unwrap();
13446        // Data row -> cell -> paragraph
13447        let cell = &rt.content[0].content.as_ref().unwrap()[1]
13448            .content
13449            .as_ref()
13450            .unwrap()[0];
13451        let para = &cell.content.as_ref().unwrap()[0];
13452        assert_eq!(
13453            para.attrs.as_ref().unwrap()["localId"],
13454            "cell-para",
13455            "paragraph localId should survive round-trip in tableCell"
13456        );
13457    }
13458
13459    #[test]
13460    fn nbsp_paragraph_with_localid_roundtrip() {
13461        // Issue #417: nbsp paragraph localId emitted as text instead of attrs
13462        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"nbsp-para"},"content":[{"type":"text","text":"\u00a0"}]}]}"#;
13463        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13464        let md = adf_to_markdown(&doc).unwrap();
13465        assert!(
13466            md.contains("::paragraph["),
13467            "nbsp should use directive form: {md}"
13468        );
13469        assert!(
13470            md.contains("localId=nbsp-para"),
13471            "localId should be in directive: {md}"
13472        );
13473        let rt = markdown_to_adf(&md).unwrap();
13474        let para = &rt.content[0];
13475        assert_eq!(
13476            para.attrs.as_ref().unwrap()["localId"],
13477            "nbsp-para",
13478            "localId should survive round-trip"
13479        );
13480        let text = para.content.as_ref().unwrap()[0].text.as_ref().unwrap();
13481        assert_eq!(text, "\u{00a0}", "nbsp should survive");
13482    }
13483
13484    #[test]
13485    fn empty_paragraph_with_localid_roundtrip() {
13486        // Empty paragraph directive with localId
13487        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"empty-para"}}]}"#;
13488        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13489        let md = adf_to_markdown(&doc).unwrap();
13490        assert!(
13491            md.contains("::paragraph{localId=empty-para}"),
13492            "empty paragraph should include localId in directive: {md}"
13493        );
13494        let rt = markdown_to_adf(&md).unwrap();
13495        assert_eq!(
13496            rt.content[0].attrs.as_ref().unwrap()["localId"],
13497            "empty-para"
13498        );
13499    }
13500
13501    #[test]
13502    fn paragraph_localid_stripped_from_list_item() {
13503        // strip_local_ids should also strip paraLocalId
13504        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"}]}]}]}]}"#;
13505        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13506        let opts = RenderOptions {
13507            strip_local_ids: true,
13508        };
13509        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13510        assert!(!md.contains("localId"), "localId should be stripped: {md}");
13511        assert!(
13512            !md.contains("paraLocalId"),
13513            "paraLocalId should be stripped: {md}"
13514        );
13515    }
13516
13517    #[test]
13518    fn date_directive() {
13519        let doc = markdown_to_adf("Due by :date[2026-04-15].").unwrap();
13520        let content = doc.content[0].content.as_ref().unwrap();
13521        assert_eq!(content[1].node_type, "date");
13522        // ISO date is converted to epoch milliseconds
13523        assert_eq!(
13524            content[1].attrs.as_ref().unwrap()["timestamp"],
13525            "1776211200000"
13526        );
13527    }
13528
13529    #[test]
13530    fn adf_date_to_markdown() {
13531        // ADF dates use epoch ms; renderer converts back to ISO with timestamp attr
13532        let doc = AdfDocument {
13533            version: 1,
13534            doc_type: "doc".to_string(),
13535            content: vec![AdfNode::paragraph(vec![AdfNode::date("1776211200000")])],
13536        };
13537        let md = adf_to_markdown(&doc).unwrap();
13538        assert!(md.contains(":date[2026-04-15]{timestamp=1776211200000}"));
13539    }
13540
13541    #[test]
13542    fn adf_date_iso_passthrough() {
13543        // If ADF already has ISO date (legacy), pass through
13544        let doc = AdfDocument {
13545            version: 1,
13546            doc_type: "doc".to_string(),
13547            content: vec![AdfNode::paragraph(vec![AdfNode::date("2026-04-15")])],
13548        };
13549        let md = adf_to_markdown(&doc).unwrap();
13550        assert!(md.contains(":date[2026-04-15]{timestamp=2026-04-15}"));
13551    }
13552
13553    #[test]
13554    fn round_trip_date() {
13555        let md = "Due by :date[2026-04-15].\n";
13556        let doc = markdown_to_adf(md).unwrap();
13557        let result = adf_to_markdown(&doc).unwrap();
13558        assert!(result.contains(":date[2026-04-15]"));
13559    }
13560
13561    #[test]
13562    fn round_trip_date_non_midnight_timestamp() {
13563        // Issue #409: non-midnight timestamps must survive round-trip
13564        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000"}}]}]}"#;
13565        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13566        let md = adf_to_markdown(&doc).unwrap();
13567        // JFM should include the original timestamp
13568        assert!(
13569            md.contains("timestamp=1700000000000"),
13570            "JFM should preserve original timestamp: {md}"
13571        );
13572        // Round-trip back to ADF
13573        let doc2 = markdown_to_adf(&md).unwrap();
13574        let content = doc2.content[0].content.as_ref().unwrap();
13575        assert_eq!(
13576            content[0].attrs.as_ref().unwrap()["timestamp"],
13577            "1700000000000",
13578            "Round-trip must preserve original non-midnight timestamp"
13579        );
13580    }
13581
13582    #[test]
13583    fn date_epoch_ms_passthrough() {
13584        // If JFM date is already epoch ms, pass through
13585        let doc = markdown_to_adf("Due by :date[1776211200000].").unwrap();
13586        let content = doc.content[0].content.as_ref().unwrap();
13587        assert_eq!(
13588            content[1].attrs.as_ref().unwrap()["timestamp"],
13589            "1776211200000"
13590        );
13591    }
13592
13593    #[test]
13594    fn date_timestamp_attr_preferred_over_content() {
13595        // When timestamp attr is present, it takes priority over the display date
13596        let md = ":date[2023-11-14]{timestamp=1700000000000}\n";
13597        let doc = markdown_to_adf(md).unwrap();
13598        let content = doc.content[0].content.as_ref().unwrap();
13599        assert_eq!(
13600            content[0].attrs.as_ref().unwrap()["timestamp"],
13601            "1700000000000",
13602            "timestamp attr should be used directly"
13603        );
13604    }
13605
13606    #[test]
13607    fn date_without_timestamp_attr_backward_compat() {
13608        // Legacy JFM without timestamp attr still works via iso_date_to_epoch_ms
13609        let md = ":date[2026-04-15]\n";
13610        let doc = markdown_to_adf(md).unwrap();
13611        let content = doc.content[0].content.as_ref().unwrap();
13612        assert_eq!(
13613            content[0].attrs.as_ref().unwrap()["timestamp"],
13614            "1776211200000",
13615            "Should fall back to computing timestamp from date string"
13616        );
13617    }
13618
13619    #[test]
13620    fn date_with_local_id_and_timestamp() {
13621        // Both localId and timestamp should round-trip
13622        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
13623        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13624        let md = adf_to_markdown(&doc).unwrap();
13625        assert!(
13626            md.contains("timestamp=1700000000000"),
13627            "Should contain timestamp: {md}"
13628        );
13629        assert!(md.contains("localId=d-001"), "Should contain localId: {md}");
13630        // Round-trip
13631        let doc2 = markdown_to_adf(&md).unwrap();
13632        let content = doc2.content[0].content.as_ref().unwrap();
13633        let attrs = content[0].attrs.as_ref().unwrap();
13634        assert_eq!(attrs["timestamp"], "1700000000000");
13635        assert_eq!(attrs["localId"], "d-001");
13636    }
13637
13638    #[test]
13639    fn mention_directive() {
13640        let doc = markdown_to_adf("Assigned to :mention[Alice]{id=abc123}.").unwrap();
13641        let content = doc.content[0].content.as_ref().unwrap();
13642        assert_eq!(content[1].node_type, "mention");
13643        assert_eq!(content[1].attrs.as_ref().unwrap()["id"], "abc123");
13644        assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "Alice");
13645    }
13646
13647    #[test]
13648    fn adf_mention_to_markdown() {
13649        let doc = AdfDocument {
13650            version: 1,
13651            doc_type: "doc".to_string(),
13652            content: vec![AdfNode::paragraph(vec![AdfNode::mention(
13653                "abc123", "Alice",
13654            )])],
13655        };
13656        let md = adf_to_markdown(&doc).unwrap();
13657        assert!(md.contains(":mention[Alice]{id=abc123}"));
13658    }
13659
13660    #[test]
13661    fn round_trip_mention() {
13662        let md = "Assigned to :mention[Alice]{id=abc123}.\n";
13663        let doc = markdown_to_adf(md).unwrap();
13664        let result = adf_to_markdown(&doc).unwrap();
13665        assert!(result.contains(":mention[Alice]{id=abc123}"));
13666    }
13667
13668    #[test]
13669    fn mention_with_empty_access_level_round_trips() {
13670        // Issue #363: accessLevel="" produces accessLevel= which failed to parse
13671        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13672          {"type":"mention","attrs":{"id":"61921b41c15977006af2b1d1","text":"@Javier Inchausti","accessLevel":""}}
13673        ]}]}"#;
13674        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13675
13676        let md = adf_to_markdown(&doc).unwrap();
13677        let round_tripped = markdown_to_adf(&md).unwrap();
13678        let mention = &round_tripped.content[0].content.as_ref().unwrap()[0];
13679        assert_eq!(
13680            mention.node_type, "mention",
13681            "mention with empty accessLevel was not parsed as mention, got: {}",
13682            mention.node_type
13683        );
13684    }
13685
13686    #[test]
13687    fn span_with_color() {
13688        let doc = markdown_to_adf("This is :span[red text]{color=#ff5630}.").unwrap();
13689        let content = doc.content[0].content.as_ref().unwrap();
13690        assert_eq!(content[1].node_type, "text");
13691        assert_eq!(content[1].text.as_deref(), Some("red text"));
13692        let marks = content[1].marks.as_ref().unwrap();
13693        assert_eq!(marks[0].mark_type, "textColor");
13694    }
13695
13696    #[test]
13697    fn adf_text_color_to_markdown() {
13698        let doc = AdfDocument {
13699            version: 1,
13700            doc_type: "doc".to_string(),
13701            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
13702                "red text",
13703                vec![AdfMark::text_color("#ff5630")],
13704            )])],
13705        };
13706        let md = adf_to_markdown(&doc).unwrap();
13707        assert!(md.contains(":span[red text]{color=#ff5630}"));
13708    }
13709
13710    #[test]
13711    fn round_trip_span_color() {
13712        let md = "This is :span[red text]{color=#ff5630}.\n";
13713        let doc = markdown_to_adf(md).unwrap();
13714        let result = adf_to_markdown(&doc).unwrap();
13715        assert!(result.contains(":span[red text]{color=#ff5630}"));
13716    }
13717
13718    #[test]
13719    fn text_color_and_link_marks_both_preserved() {
13720        // Issue #405: text with both textColor and link marks loses link on round-trip
13721        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13722          {"type":"text","text":"red link","marks":[
13723            {"type":"link","attrs":{"href":"https://example.com"}},
13724            {"type":"textColor","attrs":{"color":"#ff0000"}}
13725          ]}
13726        ]}]}"##;
13727        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13728        let md = adf_to_markdown(&doc).unwrap();
13729        assert!(
13730            md.contains(":span[red link]{color=#ff0000}"),
13731            "JFM should contain span with color, got: {md}"
13732        );
13733        assert!(
13734            md.contains("](https://example.com)"),
13735            "JFM should contain link href, got: {md}"
13736        );
13737        // Full round-trip: both marks survive
13738        let rt = markdown_to_adf(&md).unwrap();
13739        let text_node = &rt.content[0].content.as_ref().unwrap()[0];
13740        let marks = text_node.marks.as_ref().expect("should have marks");
13741        assert!(
13742            marks.iter().any(|m| m.mark_type == "textColor"),
13743            "should have textColor mark, got: {:?}",
13744            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
13745        );
13746        assert!(
13747            marks.iter().any(|m| m.mark_type == "link"),
13748            "should have link mark, got: {:?}",
13749            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
13750        );
13751        // Verify attribute values survive
13752        let link_mark = marks.iter().find(|m| m.mark_type == "link").unwrap();
13753        assert_eq!(
13754            link_mark.attrs.as_ref().unwrap()["href"],
13755            "https://example.com"
13756        );
13757        let color_mark = marks.iter().find(|m| m.mark_type == "textColor").unwrap();
13758        assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#ff0000");
13759    }
13760
13761    #[test]
13762    fn bg_color_and_link_marks_both_preserved() {
13763        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13764          {"type":"text","text":"highlighted link","marks":[
13765            {"type":"link","attrs":{"href":"https://example.com"}},
13766            {"type":"backgroundColor","attrs":{"color":"#ffff00"}}
13767          ]}
13768        ]}]}"##;
13769        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13770        let md = adf_to_markdown(&doc).unwrap();
13771        assert!(md.contains("bg=#ffff00"), "should have bg color: {md}");
13772        assert!(
13773            md.contains("](https://example.com)"),
13774            "should have link: {md}"
13775        );
13776        let rt = markdown_to_adf(&md).unwrap();
13777        let text_node = &rt.content[0].content.as_ref().unwrap()[0];
13778        let marks = text_node.marks.as_ref().expect("should have marks");
13779        assert!(marks.iter().any(|m| m.mark_type == "backgroundColor"));
13780        assert!(marks.iter().any(|m| m.mark_type == "link"));
13781    }
13782
13783    #[test]
13784    fn text_color_link_and_strong_rendering() {
13785        // Verify textColor + link + strong renders all three formatting elements
13786        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13787          {"type":"text","text":"bold red link","marks":[
13788            {"type":"strong"},
13789            {"type":"link","attrs":{"href":"https://example.com"}},
13790            {"type":"textColor","attrs":{"color":"#ff0000"}}
13791          ]}
13792        ]}]}"##;
13793        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13794        let md = adf_to_markdown(&doc).unwrap();
13795        assert!(
13796            md.starts_with("**") && md.trim().ends_with("**"),
13797            "should have bold wrapping: {md}"
13798        );
13799        assert!(md.contains("color=#ff0000"), "should have color: {md}");
13800        assert!(
13801            md.contains("](https://example.com)"),
13802            "should have link: {md}"
13803        );
13804    }
13805
13806    #[test]
13807    fn subsup_and_link_marks_both_preserved() {
13808        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13809          {"type":"text","text":"note","marks":[
13810            {"type":"link","attrs":{"href":"https://example.com"}},
13811            {"type":"subsup","attrs":{"type":"sup"}}
13812          ]}
13813        ]}]}"#;
13814        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13815        let md = adf_to_markdown(&doc).unwrap();
13816        assert!(md.contains("sup"), "should have sup: {md}");
13817        assert!(
13818            md.contains("](https://example.com)"),
13819            "should have link: {md}"
13820        );
13821        let rt = markdown_to_adf(&md).unwrap();
13822        let text_node = &rt.content[0].content.as_ref().unwrap()[0];
13823        let marks = text_node.marks.as_ref().expect("should have marks");
13824        assert!(marks.iter().any(|m| m.mark_type == "subsup"));
13825        assert!(marks.iter().any(|m| m.mark_type == "link"));
13826    }
13827
13828    #[test]
13829    fn text_color_without_link_unchanged() {
13830        // Regression guard: textColor without link should still work
13831        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13832          {"type":"text","text":"just red","marks":[
13833            {"type":"textColor","attrs":{"color":"#ff0000"}}
13834          ]}
13835        ]}]}"##;
13836        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13837        let md = adf_to_markdown(&doc).unwrap();
13838        assert!(md.contains(":span[just red]{color=#ff0000}"), "md: {md}");
13839        assert!(!md.contains("](http"), "should NOT have link syntax: {md}");
13840    }
13841
13842    #[test]
13843    fn inline_extension_directive() {
13844        let doc =
13845            markdown_to_adf("See :extension[fallback]{type=com.app key=widget} here.").unwrap();
13846        let content = doc.content[0].content.as_ref().unwrap();
13847        assert_eq!(content[1].node_type, "inlineExtension");
13848        assert_eq!(
13849            content[1].attrs.as_ref().unwrap()["extensionType"],
13850            "com.app"
13851        );
13852        assert_eq!(content[1].attrs.as_ref().unwrap()["extensionKey"], "widget");
13853    }
13854
13855    #[test]
13856    fn adf_inline_extension_to_markdown() {
13857        let doc = AdfDocument {
13858            version: 1,
13859            doc_type: "doc".to_string(),
13860            content: vec![AdfNode::paragraph(vec![AdfNode::inline_extension(
13861                "com.app",
13862                "widget",
13863                Some("fallback"),
13864            )])],
13865        };
13866        let md = adf_to_markdown(&doc).unwrap();
13867        assert!(md.contains(":extension[fallback]{type=com.app key=widget}"));
13868    }
13869
13870    // ── Helper function tests ──────────────────────────────────────────
13871
13872    #[test]
13873    fn parse_ordered_list_marker_valid() {
13874        let result = parse_ordered_list_marker("1. Hello");
13875        assert_eq!(result, Some((1, "Hello")));
13876    }
13877
13878    #[test]
13879    fn parse_ordered_list_marker_high_number() {
13880        let result = parse_ordered_list_marker("42. Item");
13881        assert_eq!(result, Some((42, "Item")));
13882    }
13883
13884    #[test]
13885    fn parse_ordered_list_marker_not_a_list() {
13886        assert!(parse_ordered_list_marker("not a list").is_none());
13887        assert!(parse_ordered_list_marker("1.no space").is_none());
13888    }
13889
13890    #[test]
13891    fn is_list_start_various() {
13892        assert!(is_list_start("- item"));
13893        assert!(is_list_start("* item"));
13894        assert!(is_list_start("+ item"));
13895        assert!(is_list_start("1. item"));
13896        assert!(!is_list_start("not a list"));
13897    }
13898
13899    #[test]
13900    fn is_horizontal_rule_various() {
13901        assert!(is_horizontal_rule("---"));
13902        assert!(is_horizontal_rule("***"));
13903        assert!(is_horizontal_rule("___"));
13904        assert!(is_horizontal_rule("------"));
13905        assert!(!is_horizontal_rule("--"));
13906        assert!(!is_horizontal_rule("abc"));
13907    }
13908
13909    #[test]
13910    fn is_table_separator_valid() {
13911        assert!(is_table_separator("| --- | --- |"));
13912        assert!(is_table_separator("|:---:|:---|"));
13913        assert!(!is_table_separator("no pipes here"));
13914    }
13915
13916    #[test]
13917    fn parse_table_row_cells() {
13918        let cells = parse_table_row("| A | B | C |");
13919        assert_eq!(cells, vec!["A", "B", "C"]);
13920    }
13921
13922    #[test]
13923    fn parse_table_row_escaped_pipe_in_cell() {
13924        // Issue #579: `\|` inside a cell is a literal pipe, not a column separator.
13925        let cells = parse_table_row(r"| a\|b | c |");
13926        assert_eq!(cells, vec!["a|b", "c"]);
13927    }
13928
13929    #[test]
13930    fn parse_table_row_escaped_pipe_in_code_span() {
13931        // Issue #579: `\|` inside an inline code span is unescaped at the row level.
13932        let cells = parse_table_row(r"| `parser.decode[T\|json]` | other |");
13933        assert_eq!(cells, vec!["`parser.decode[T|json]`", "other"]);
13934    }
13935
13936    #[test]
13937    fn parse_table_row_preserves_other_backslashes() {
13938        // Only `\|` is special at the row-splitting level; other backslashes pass through.
13939        let cells = parse_table_row(r"| a\\b | c\*d |");
13940        assert_eq!(cells, vec![r"a\\b", r"c\*d"]);
13941    }
13942
13943    #[test]
13944    fn parse_image_syntax_valid() {
13945        let result = parse_image_syntax("![alt](url)");
13946        assert_eq!(result, Some(("alt", "url")));
13947    }
13948
13949    #[test]
13950    fn parse_image_syntax_not_image() {
13951        assert!(parse_image_syntax("not an image").is_none());
13952    }
13953
13954    // ── find_closing_paren tests ────────────────────────────────────
13955
13956    #[test]
13957    fn find_closing_paren_simple() {
13958        assert_eq!(find_closing_paren("(hello)", 0), Some(6));
13959    }
13960
13961    #[test]
13962    fn find_closing_paren_nested() {
13963        assert_eq!(find_closing_paren("(a(b)c)", 0), Some(6));
13964    }
13965
13966    #[test]
13967    fn find_closing_paren_unmatched() {
13968        assert_eq!(find_closing_paren("(no close", 0), None);
13969    }
13970
13971    #[test]
13972    fn find_closing_paren_offset() {
13973        // Start scanning from the second '('
13974        assert_eq!(find_closing_paren("xx(inner)", 2), Some(8));
13975    }
13976
13977    // ── Parentheses-in-URL tests (issue #509) ──────────────────────
13978
13979    #[test]
13980    fn try_parse_link_url_with_parens() {
13981        let input = "[here](https://example.com/faq#access-(permissions)-rest)";
13982        let result = try_parse_link(input, 0);
13983        assert_eq!(
13984            result,
13985            Some((
13986                input.len(),
13987                "here",
13988                "https://example.com/faq#access-(permissions)-rest"
13989            ))
13990        );
13991    }
13992
13993    #[test]
13994    fn try_parse_link_url_no_parens() {
13995        let input = "[text](https://example.com)";
13996        let result = try_parse_link(input, 0);
13997        assert_eq!(result, Some((input.len(), "text", "https://example.com")));
13998    }
13999
14000    #[test]
14001    fn try_parse_link_url_with_multiple_nested_parens() {
14002        let input = "[x](http://en.wikipedia.org/wiki/Foo_(bar_(baz)))";
14003        let result = try_parse_link(input, 0);
14004        assert_eq!(
14005            result,
14006            Some((
14007                input.len(),
14008                "x",
14009                "http://en.wikipedia.org/wiki/Foo_(bar_(baz))"
14010            ))
14011        );
14012    }
14013
14014    #[test]
14015    fn parse_image_syntax_url_with_parens() {
14016        let result = parse_image_syntax("![alt](https://example.com/page_(1))");
14017        assert_eq!(result, Some(("alt", "https://example.com/page_(1)")));
14018    }
14019
14020    #[test]
14021    fn parse_image_syntax_url_no_parens() {
14022        let result = parse_image_syntax("![alt](https://example.com)");
14023        assert_eq!(result, Some(("alt", "https://example.com")));
14024    }
14025
14026    #[test]
14027    fn link_with_parens_round_trip() {
14028        let href = "https://example.com/faq#I-need-access-(permissions)-added-in-Monitor";
14029        let mut text_node = AdfNode::text("here");
14030        text_node.marks = Some(vec![AdfMark::link(href)]);
14031        let adf_input = AdfDocument {
14032            version: 1,
14033            doc_type: "doc".to_string(),
14034            content: vec![AdfNode::paragraph(vec![text_node])],
14035        };
14036
14037        let jfm = adf_to_markdown(&adf_input).unwrap();
14038        let adf_output = markdown_to_adf(&jfm).unwrap();
14039
14040        // Extract the href from the round-tripped ADF
14041        let para = &adf_output.content[0];
14042        let text_node = &para.content.as_ref().unwrap()[0];
14043        let mark = &text_node.marks.as_ref().unwrap()[0];
14044        let result_href = mark.attrs.as_ref().unwrap()["href"].as_str().unwrap();
14045
14046        assert_eq!(result_href, href);
14047    }
14048
14049    #[test]
14050    fn flush_plain_empty_range() {
14051        let mut nodes = Vec::new();
14052        flush_plain("hello", 3, 3, &mut nodes);
14053        assert!(nodes.is_empty());
14054    }
14055
14056    #[test]
14057    fn add_mark_to_unmarked_node() {
14058        let mut node = AdfNode::text("test");
14059        add_mark(&mut node, AdfMark::strong());
14060        assert_eq!(node.marks.as_ref().unwrap().len(), 1);
14061    }
14062
14063    #[test]
14064    fn add_mark_to_marked_node() {
14065        let mut node = AdfNode::text_with_marks("test", vec![AdfMark::strong()]);
14066        add_mark(&mut node, AdfMark::em());
14067        assert_eq!(node.marks.as_ref().unwrap().len(), 2);
14068    }
14069
14070    // ── Directive table tests ──────────────────────────────────────
14071
14072    #[test]
14073    fn directive_table_basic() {
14074        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";
14075        let doc = markdown_to_adf(md).unwrap();
14076        assert_eq!(doc.content[0].node_type, "table");
14077        let rows = doc.content[0].content.as_ref().unwrap();
14078        assert_eq!(rows.len(), 2);
14079        assert_eq!(
14080            rows[0].content.as_ref().unwrap()[0].node_type,
14081            "tableHeader"
14082        );
14083        assert_eq!(rows[1].content.as_ref().unwrap()[0].node_type, "tableCell");
14084    }
14085
14086    #[test]
14087    fn directive_table_with_block_content() {
14088        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";
14089        let doc = markdown_to_adf(md).unwrap();
14090        let rows = doc.content[0].content.as_ref().unwrap();
14091        let cell = &rows[0].content.as_ref().unwrap()[0];
14092        // Cell should have block content (paragraph + bullet list)
14093        let content = cell.content.as_ref().unwrap();
14094        assert!(content.len() >= 2);
14095        assert_eq!(content[1].node_type, "bulletList");
14096    }
14097
14098    #[test]
14099    fn directive_table_with_cell_attrs() {
14100        let md = "::::table\n:::tr\n:::td{colspan=2 bg=#DEEBFF}\nSpanning cell\n:::\n:::\n::::\n";
14101        let doc = markdown_to_adf(md).unwrap();
14102        let cell = &doc.content[0].content.as_ref().unwrap()[0]
14103            .content
14104            .as_ref()
14105            .unwrap()[0];
14106        let attrs = cell.attrs.as_ref().unwrap();
14107        assert_eq!(attrs["colspan"], 2);
14108        assert_eq!(attrs["background"], "#DEEBFF");
14109    }
14110
14111    #[test]
14112    fn directive_table_with_css_var_background() {
14113        let bg = "var(--ds-background-accent-gray-subtlest, var(--ds-background-accent-gray-subtlest, #F1F2F4))";
14114        let md = format!("::::table\n:::tr\n:::th{{bg=\"{bg}\"}}\nHeader\n:::\n:::\n::::\n");
14115        let doc = markdown_to_adf(&md).unwrap();
14116        let row = &doc.content[0].content.as_ref().unwrap()[0];
14117        let cells = row.content.as_ref().unwrap();
14118        assert_eq!(cells.len(), 1, "row must have at least one cell");
14119        let attrs = cells[0].attrs.as_ref().unwrap();
14120        assert_eq!(attrs["background"], bg);
14121    }
14122
14123    #[test]
14124    fn css_var_background_round_trips() {
14125        let bg = "var(--ds-background-accent-gray-subtlest, #F1F2F4)";
14126        let adf = AdfDocument {
14127            version: 1,
14128            doc_type: "doc".to_string(),
14129            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
14130                AdfNode::table_header_with_attrs(
14131                    vec![AdfNode::paragraph(vec![AdfNode::text("Header")])],
14132                    serde_json::json!({"background": bg}),
14133                ),
14134            ])])],
14135        };
14136        let md = adf_to_markdown(&adf).unwrap();
14137        assert!(
14138            md.contains(&format!("bg=\"{bg}\"")),
14139            "bg value must be quoted in markdown: {md}"
14140        );
14141
14142        let round_tripped = markdown_to_adf(&md).unwrap();
14143        let row = &round_tripped.content[0].content.as_ref().unwrap()[0];
14144        let cells = row.content.as_ref().unwrap();
14145        assert_eq!(cells.len(), 1, "round-tripped row must have one cell");
14146        let rt_attrs = cells[0].attrs.as_ref().unwrap();
14147        assert_eq!(rt_attrs["background"], bg);
14148    }
14149
14150    #[test]
14151    fn directive_table_with_table_attrs() {
14152        let md = "::::table{layout=wide numbered}\n:::tr\n:::td\nCell\n:::\n:::\n::::\n";
14153        let doc = markdown_to_adf(md).unwrap();
14154        let attrs = doc.content[0].attrs.as_ref().unwrap();
14155        assert_eq!(attrs["layout"], "wide");
14156        assert_eq!(attrs["isNumberColumnEnabled"], true);
14157    }
14158
14159    #[test]
14160    fn adf_table_with_block_content_renders_directive_form() {
14161        // Table with a bullet list in a cell → should render as ::::table directive
14162        let doc = AdfDocument {
14163            version: 1,
14164            doc_type: "doc".to_string(),
14165            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
14166                AdfNode::table_cell(vec![
14167                    AdfNode::paragraph(vec![AdfNode::text("Cell with list:")]),
14168                    AdfNode::bullet_list(vec![AdfNode::list_item(vec![AdfNode::paragraph(vec![
14169                        AdfNode::text("Item 1"),
14170                    ])])]),
14171                ]),
14172            ])])],
14173        };
14174        let md = adf_to_markdown(&doc).unwrap();
14175        assert!(md.contains("::::table"));
14176        assert!(md.contains(":::td"));
14177        assert!(md.contains("- Item 1"));
14178    }
14179
14180    #[test]
14181    fn adf_table_inline_only_renders_pipe_form() {
14182        // Table with only inline content → pipe table
14183        let doc = AdfDocument {
14184            version: 1,
14185            doc_type: "doc".to_string(),
14186            content: vec![AdfNode::table(vec![
14187                AdfNode::table_row(vec![
14188                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
14189                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
14190                ]),
14191                AdfNode::table_row(vec![
14192                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C1")])]),
14193                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
14194                ]),
14195            ])],
14196        };
14197        let md = adf_to_markdown(&doc).unwrap();
14198        assert!(md.contains("| H1 | H2 |"));
14199        assert!(!md.contains("::::table"));
14200    }
14201
14202    #[test]
14203    fn adf_table_header_outside_first_row_renders_directive() {
14204        let doc = AdfDocument {
14205            version: 1,
14206            doc_type: "doc".to_string(),
14207            content: vec![AdfNode::table(vec![
14208                AdfNode::table_row(vec![
14209                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H")])]),
14210                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C")])]),
14211                ]),
14212                AdfNode::table_row(vec![
14213                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
14214                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
14215                ]),
14216            ])],
14217        };
14218        let md = adf_to_markdown(&doc).unwrap();
14219        assert!(md.contains("::::table"));
14220        assert!(md.contains(":::th"));
14221    }
14222
14223    #[test]
14224    fn adf_table_cell_attrs_rendered() {
14225        let doc = AdfDocument {
14226            version: 1,
14227            doc_type: "doc".to_string(),
14228            content: vec![AdfNode::table(vec![
14229                AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
14230                    AdfNode::text("H"),
14231                ])])]),
14232                AdfNode::table_row(vec![AdfNode::table_cell_with_attrs(
14233                    vec![AdfNode::paragraph(vec![AdfNode::text("C")])],
14234                    serde_json::json!({"background": "#DEEBFF", "colspan": 2}),
14235                )]),
14236            ])],
14237        };
14238        let md = adf_to_markdown(&doc).unwrap();
14239        assert!(md.contains("{colspan=2 bg=#DEEBFF}"));
14240    }
14241
14242    // ── Pipe table cell attrs tests ────────────────────────────────
14243
14244    #[test]
14245    fn pipe_table_cell_attrs() {
14246        let md = "| H1 | H2 |\n|---|---|\n| {bg=#DEEBFF} highlighted | normal |\n";
14247        let doc = markdown_to_adf(md).unwrap();
14248        let rows = doc.content[0].content.as_ref().unwrap();
14249        let cell = &rows[1].content.as_ref().unwrap()[0];
14250        let attrs = cell.attrs.as_ref().unwrap();
14251        assert_eq!(attrs["background"], "#DEEBFF");
14252    }
14253
14254    #[test]
14255    fn pipe_table_cell_colspan() {
14256        let md = "| H1 | H2 |\n|---|---|\n| {colspan=2} spanning |\n";
14257        let doc = markdown_to_adf(md).unwrap();
14258        let rows = doc.content[0].content.as_ref().unwrap();
14259        let cell = &rows[1].content.as_ref().unwrap()[0];
14260        let attrs = cell.attrs.as_ref().unwrap();
14261        assert_eq!(attrs["colspan"], 2);
14262    }
14263
14264    #[test]
14265    fn trailing_space_after_mention_in_table_cell_preserved() {
14266        // Issue #372: trailing space after mention in table cell was dropped
14267        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":[
14268          {"type":"mention","attrs":{"id":"aaa","text":"@Rob"}},
14269          {"type":"text","text":" "}
14270        ]}]}]}]}]}"#;
14271        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14272        let md = adf_to_markdown(&doc).unwrap();
14273        let round_tripped = markdown_to_adf(&md).unwrap();
14274        let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14275            .content
14276            .as_ref()
14277            .unwrap()[0];
14278        let para = &cell.content.as_ref().unwrap()[0];
14279        let inlines = para.content.as_ref().unwrap();
14280        assert!(
14281            inlines.len() >= 2,
14282            "expected mention + text(' ') nodes, got {} nodes: {:?}",
14283            inlines.len(),
14284            inlines.iter().map(|n| &n.node_type).collect::<Vec<_>>()
14285        );
14286        assert_eq!(inlines[0].node_type, "mention");
14287        assert_eq!(inlines[1].node_type, "text");
14288        assert_eq!(inlines[1].text.as_deref(), Some(" "));
14289    }
14290
14291    // ── Column alignment tests ─────────────────────────────────────
14292
14293    #[test]
14294    fn pipe_table_column_alignment() {
14295        let md = "| Left | Center | Right |\n|:---|:---:|---:|\n| L | C | R |\n";
14296        let doc = markdown_to_adf(md).unwrap();
14297        let rows = doc.content[0].content.as_ref().unwrap();
14298        // Header row
14299        let h_cells = rows[0].content.as_ref().unwrap();
14300        // Left → no mark
14301        assert!(h_cells[0].content.as_ref().unwrap()[0].marks.is_none());
14302        // Center → alignment center
14303        let center_marks = h_cells[1].content.as_ref().unwrap()[0]
14304            .marks
14305            .as_ref()
14306            .unwrap();
14307        assert_eq!(center_marks[0].attrs.as_ref().unwrap()["align"], "center");
14308        // Right → alignment end
14309        let right_marks = h_cells[2].content.as_ref().unwrap()[0]
14310            .marks
14311            .as_ref()
14312            .unwrap();
14313        assert_eq!(right_marks[0].attrs.as_ref().unwrap()["align"], "end");
14314    }
14315
14316    #[test]
14317    fn adf_table_alignment_roundtrip() {
14318        let doc = AdfDocument {
14319            version: 1,
14320            doc_type: "doc".to_string(),
14321            content: vec![AdfNode::table(vec![
14322                AdfNode::table_row(vec![
14323                    AdfNode::table_header(vec![{
14324                        let mut p = AdfNode::paragraph(vec![AdfNode::text("Center")]);
14325                        p.marks = Some(vec![AdfMark::alignment("center")]);
14326                        p
14327                    }]),
14328                    AdfNode::table_header(vec![{
14329                        let mut p = AdfNode::paragraph(vec![AdfNode::text("Right")]);
14330                        p.marks = Some(vec![AdfMark::alignment("end")]);
14331                        p
14332                    }]),
14333                ]),
14334                AdfNode::table_row(vec![
14335                    AdfNode::table_cell(vec![{
14336                        let mut p = AdfNode::paragraph(vec![AdfNode::text("C")]);
14337                        p.marks = Some(vec![AdfMark::alignment("center")]);
14338                        p
14339                    }]),
14340                    AdfNode::table_cell(vec![{
14341                        let mut p = AdfNode::paragraph(vec![AdfNode::text("R")]);
14342                        p.marks = Some(vec![AdfMark::alignment("end")]);
14343                        p
14344                    }]),
14345                ]),
14346            ])],
14347        };
14348        let md = adf_to_markdown(&doc).unwrap();
14349        assert!(md.contains(":---:"));
14350        assert!(md.contains("---:"));
14351    }
14352
14353    // ── Panel custom attrs tests ───────────────────────────────────
14354
14355    #[test]
14356    fn panel_custom_attrs_round_trip() {
14357        let md = ":::panel{type=custom icon=\":star:\" color=\"#DEEBFF\"}\nContent\n:::\n";
14358        let doc = markdown_to_adf(md).unwrap();
14359        let panel = &doc.content[0];
14360        let attrs = panel.attrs.as_ref().unwrap();
14361        assert_eq!(attrs["panelType"], "custom");
14362        assert_eq!(attrs["panelIcon"], ":star:");
14363        assert_eq!(attrs["panelColor"], "#DEEBFF");
14364
14365        let result = adf_to_markdown(&doc).unwrap();
14366        assert!(result.contains("type=custom"));
14367        assert!(result.contains("icon="));
14368        assert!(result.contains("color="));
14369    }
14370
14371    // ── Block card with attrs tests ────────────────────────────────
14372
14373    #[test]
14374    fn block_card_with_layout() {
14375        let md = "::card[https://example.com]{layout=wide}\n";
14376        let doc = markdown_to_adf(md).unwrap();
14377        let attrs = doc.content[0].attrs.as_ref().unwrap();
14378        assert_eq!(attrs["layout"], "wide");
14379
14380        let result = adf_to_markdown(&doc).unwrap();
14381        assert!(result.contains("::card[https://example.com]{layout=wide}"));
14382    }
14383
14384    // ── Extension params test ──────────────────────────────────────
14385
14386    #[test]
14387    fn extension_with_params() {
14388        let md = r#"::extension{type=com.atlassian.macro key=jira-chart params='{"jql":"project=PROJ"}'}"#;
14389        let doc = markdown_to_adf(&format!("{md}\n")).unwrap();
14390        let attrs = doc.content[0].attrs.as_ref().unwrap();
14391        assert_eq!(attrs["parameters"]["jql"], "project=PROJ");
14392    }
14393
14394    #[test]
14395    fn leaf_extension_layout_preserved_in_roundtrip() {
14396        // Issue #381: layout attr on extension nodes was dropped
14397        let adf_json = r#"{"version":1,"type":"doc","content":[
14398          {"type":"extension","attrs":{"extensionType":"com.atlassian.confluence.macro.core","extensionKey":"toc","layout":"default","parameters":{}}}
14399        ]}"#;
14400        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14401        let md = adf_to_markdown(&doc).unwrap();
14402        assert!(
14403            md.contains("layout=default"),
14404            "JFM should contain layout=default, got: {md}"
14405        );
14406        let round_tripped = markdown_to_adf(&md).unwrap();
14407        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14408        assert_eq!(attrs["layout"], "default", "layout should be preserved");
14409        assert_eq!(attrs["extensionKey"], "toc");
14410    }
14411
14412    #[test]
14413    fn bodied_extension_layout_preserved_in_roundtrip() {
14414        // Bodied extension with layout
14415        let adf_json = r#"{"version":1,"type":"doc","content":[
14416          {"type":"bodiedExtension","attrs":{"extensionType":"com.atlassian.macro","extensionKey":"expand","layout":"wide"},
14417           "content":[{"type":"paragraph","content":[{"type":"text","text":"inner"}]}]}
14418        ]}"#;
14419        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14420        let md = adf_to_markdown(&doc).unwrap();
14421        assert!(
14422            md.contains("layout=wide"),
14423            "JFM should contain layout=wide, got: {md}"
14424        );
14425        let round_tripped = markdown_to_adf(&md).unwrap();
14426        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14427        assert_eq!(attrs["layout"], "wide", "layout should be preserved");
14428    }
14429
14430    #[test]
14431    fn bodied_extension_parameters_preserved_in_roundtrip() {
14432        // Issue #473: parameters block inside bodiedExtension.attrs was dropped
14433        let adf_json = r#"{"version":1,"type":"doc","content":[
14434          {"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":{}}},
14435           "content":[{"type":"paragraph","content":[{"type":"text","text":"Content inside bodied extension"}]}]}
14436        ]}"#;
14437        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14438        let md = adf_to_markdown(&doc).unwrap();
14439        assert!(
14440            md.contains("params="),
14441            "JFM should contain params attribute, got: {md}"
14442        );
14443        let round_tripped = markdown_to_adf(&md).unwrap();
14444        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14445        assert_eq!(
14446            attrs["parameters"]["macroMetadata"]["title"], "Page Properties",
14447            "parameters should be preserved in round-trip"
14448        );
14449        assert_eq!(attrs["extensionKey"], "details");
14450        assert_eq!(attrs["layout"], "default");
14451        assert_eq!(attrs["localId"], "aabbccdd-1234");
14452    }
14453
14454    #[test]
14455    fn bodied_extension_malformed_params_ignored() {
14456        // Malformed params JSON should be silently ignored, not crash
14457        let md = ":::extension{type=com.atlassian.macro key=details params='not-valid-json'}\nContent\n:::\n";
14458        let doc = markdown_to_adf(md).unwrap();
14459        let attrs = doc.content[0].attrs.as_ref().unwrap();
14460        assert_eq!(attrs["extensionKey"], "details");
14461        // parameters should be absent since the JSON was invalid
14462        assert!(attrs.get("parameters").is_none());
14463    }
14464
14465    #[test]
14466    fn leaf_extension_localid_preserved_in_roundtrip() {
14467        // Extension with both layout and localId
14468        let adf_json = r#"{"version":1,"type":"doc","content":[
14469          {"type":"extension","attrs":{"extensionType":"com.atlassian.macro","extensionKey":"toc","layout":"default","localId":"abc-123"}}
14470        ]}"#;
14471        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14472        let md = adf_to_markdown(&doc).unwrap();
14473        let round_tripped = markdown_to_adf(&md).unwrap();
14474        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14475        assert_eq!(attrs["layout"], "default");
14476        assert_eq!(attrs["localId"], "abc-123");
14477    }
14478
14479    // ── Mention with userType test ─────────────────────────────────
14480
14481    #[test]
14482    fn mention_with_user_type() {
14483        let md = "Hi :mention[Alice]{id=abc123 userType=DEFAULT}.\n";
14484        let doc = markdown_to_adf(md).unwrap();
14485        let mention = &doc.content[0].content.as_ref().unwrap()[1];
14486        assert_eq!(mention.attrs.as_ref().unwrap()["userType"], "DEFAULT");
14487
14488        let result = adf_to_markdown(&doc).unwrap();
14489        assert!(result.contains("userType=DEFAULT"));
14490    }
14491
14492    // ── Colwidth tests ─────────────────────────────────────────────
14493
14494    #[test]
14495    fn directive_table_colwidth() {
14496        let md = "::::table\n:::tr\n:::td{colwidth=100,200}\nCell\n:::\n:::\n::::\n";
14497        let doc = markdown_to_adf(md).unwrap();
14498        let cell = &doc.content[0].content.as_ref().unwrap()[0]
14499            .content
14500            .as_ref()
14501            .unwrap()[0];
14502        let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
14503        assert_eq!(colwidth, &[serde_json::json!(100), serde_json::json!(200)]);
14504    }
14505
14506    #[test]
14507    fn directive_table_colwidth_float_roundtrip() {
14508        // Confluence returns colwidth as floats (e.g. 157.0, 863.0).
14509        // adf_to_markdown must preserve them so markdown_to_adf can restore them.
14510        let adf_doc = serde_json::json!({
14511            "type": "doc",
14512            "version": 1,
14513            "content": [{
14514                "type": "table",
14515                "content": [{
14516                    "type": "tableRow",
14517                    "content": [
14518                        {
14519                            "type": "tableHeader",
14520                            "attrs": { "colwidth": [157.0] },
14521                            "content": [{ "type": "paragraph" }]
14522                        },
14523                        {
14524                            "type": "tableHeader",
14525                            "attrs": { "colwidth": [863.0] },
14526                            "content": [{ "type": "paragraph" }]
14527                        }
14528                    ]
14529                }]
14530            }]
14531        });
14532        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
14533        let md = adf_to_markdown(&doc).unwrap();
14534        assert!(
14535            md.contains("colwidth=157.0"),
14536            "expected colwidth=157.0 in markdown, got: {md}"
14537        );
14538        assert!(
14539            md.contains("colwidth=863.0"),
14540            "expected colwidth=863.0 in markdown, got: {md}"
14541        );
14542        // Round-trip back to ADF
14543        let doc2 = markdown_to_adf(&md).unwrap();
14544        let row = &doc2.content[0].content.as_ref().unwrap()[0];
14545        let header1 = &row.content.as_ref().unwrap()[0];
14546        let header2 = &row.content.as_ref().unwrap()[1];
14547        assert_eq!(
14548            header1.attrs.as_ref().unwrap()["colwidth"]
14549                .as_array()
14550                .unwrap(),
14551            &[serde_json::json!(157.0)]
14552        );
14553        assert_eq!(
14554            header2.attrs.as_ref().unwrap()["colwidth"]
14555                .as_array()
14556                .unwrap(),
14557            &[serde_json::json!(863.0)]
14558        );
14559    }
14560
14561    #[test]
14562    fn colwidth_float_preserved_in_roundtrip() {
14563        // Issue #369: colwidth 254.0 was coerced to integer 254
14564        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":[]}]}]}]}]}"#;
14565        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14566        let md = adf_to_markdown(&doc).unwrap();
14567        let round_tripped = markdown_to_adf(&md).unwrap();
14568        let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14569            .content
14570            .as_ref()
14571            .unwrap()[0];
14572        let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
14573        assert_eq!(
14574            colwidth,
14575            &[serde_json::json!(254.0), serde_json::json!(416.0)],
14576            "colwidth should preserve float values"
14577        );
14578    }
14579
14580    #[test]
14581    fn colwidth_integer_preserved_in_roundtrip() {
14582        // Issue #459: colwidth integer values emitted as floats after round-trip
14583        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"}]}]}]}]}]}"#;
14584        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14585        let md = adf_to_markdown(&doc).unwrap();
14586        assert!(
14587            md.contains("colwidth=150"),
14588            "expected colwidth=150 (no decimal) in markdown, got: {md}"
14589        );
14590        assert!(
14591            !md.contains("colwidth=150.0"),
14592            "colwidth should not have .0 suffix for integers, got: {md}"
14593        );
14594        // Round-trip back to ADF
14595        let round_tripped = markdown_to_adf(&md).unwrap();
14596        let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14597            .content
14598            .as_ref()
14599            .unwrap()[0];
14600        let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
14601        assert_eq!(
14602            colwidth,
14603            &[serde_json::json!(150)],
14604            "colwidth should preserve integer values"
14605        );
14606        // Verify JSON serialization uses integer, not float
14607        let json_output = serde_json::to_string(&round_tripped).unwrap();
14608        assert!(
14609            json_output.contains(r#""colwidth":[150]"#),
14610            "JSON should contain integer colwidth, got: {json_output}"
14611        );
14612    }
14613
14614    #[test]
14615    fn colwidth_mixed_int_and_float_roundtrip() {
14616        // Integer colwidth from standard ADF and float colwidth from Confluence
14617        // should each preserve their original type through round-trip.
14618        let int_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colwidth":[100,200]}}]}]}]}"#;
14619        let float_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colwidth":[100.0,200.0]}}]}]}]}"#;
14620
14621        // Integer input → integer output
14622        let int_doc: AdfDocument = serde_json::from_str(int_json).unwrap();
14623        let int_md = adf_to_markdown(&int_doc).unwrap();
14624        assert!(
14625            int_md.contains("colwidth=100,200"),
14626            "integer colwidth in md: {int_md}"
14627        );
14628        let int_rt = markdown_to_adf(&int_md).unwrap();
14629        let int_serial = serde_json::to_string(&int_rt).unwrap();
14630        assert!(
14631            int_serial.contains(r#""colwidth":[100,200]"#),
14632            "integer colwidth in JSON: {int_serial}"
14633        );
14634
14635        // Float input → float output
14636        let float_doc: AdfDocument = serde_json::from_str(float_json).unwrap();
14637        let float_md = adf_to_markdown(&float_doc).unwrap();
14638        assert!(
14639            float_md.contains("colwidth=100.0,200.0"),
14640            "float colwidth in md: {float_md}"
14641        );
14642        let float_rt = markdown_to_adf(&float_md).unwrap();
14643        let float_serial = serde_json::to_string(&float_rt).unwrap();
14644        assert!(
14645            float_serial.contains(r#""colwidth":[100.0,200.0]"#),
14646            "float colwidth in JSON: {float_serial}"
14647        );
14648    }
14649
14650    #[test]
14651    fn colwidth_fractional_float_preserved() {
14652        // Covers the fractional-float branch (n.fract() != 0.0) in build_cell_attrs_string
14653        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"}]}]}]}]}]}"#;
14654        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14655        let md = adf_to_markdown(&doc).unwrap();
14656        assert!(
14657            md.contains("colwidth=100.5"),
14658            "expected colwidth=100.5 in markdown, got: {md}"
14659        );
14660    }
14661
14662    #[test]
14663    fn colwidth_non_numeric_values_skipped() {
14664        // Covers the None branch for non-numeric colwidth entries in build_cell_attrs_string
14665        let adf_doc = serde_json::json!({
14666            "type": "doc",
14667            "version": 1,
14668            "content": [{
14669                "type": "table",
14670                "content": [{
14671                    "type": "tableRow",
14672                    "content": [{
14673                        "type": "tableCell",
14674                        "attrs": { "colwidth": ["invalid"] },
14675                        "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "cell" }] }]
14676                    }]
14677                }]
14678            }]
14679        });
14680        let doc: AdfDocument = serde_json::from_value(adf_doc).unwrap();
14681        let md = adf_to_markdown(&doc).unwrap();
14682        // Non-numeric values are filtered out, so colwidth should not appear
14683        assert!(
14684            !md.contains("colwidth"),
14685            "non-numeric colwidth should be filtered out, got: {md}"
14686        );
14687    }
14688
14689    #[test]
14690    fn default_rowspan_colspan_preserved_in_roundtrip() {
14691        // Issue #369: rowspan=1 and colspan=1 were elided
14692        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"}]}]}]}]}]}"#;
14693        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14694        let md = adf_to_markdown(&doc).unwrap();
14695        let round_tripped = markdown_to_adf(&md).unwrap();
14696        let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14697            .content
14698            .as_ref()
14699            .unwrap()[0];
14700        let attrs = cell.attrs.as_ref().unwrap();
14701        assert_eq!(attrs["rowspan"], 1, "rowspan=1 should be preserved");
14702        assert_eq!(attrs["colspan"], 1, "colspan=1 should be preserved");
14703    }
14704
14705    // ── Nested list tests ──────────────────────────────────────────────
14706
14707    #[test]
14708    fn table_localid_preserved_in_roundtrip() {
14709        // Issue #374: localId on table nodes was dropped
14710        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"}]}]}]}]}]}"#;
14711        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14712        let md = adf_to_markdown(&doc).unwrap();
14713        assert!(
14714            md.contains("localId="),
14715            "JFM should contain localId, got: {md}"
14716        );
14717        let round_tripped = markdown_to_adf(&md).unwrap();
14718        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14719        assert_eq!(
14720            attrs["localId"], "7afd4550-e66c-4b12-875f-a91c6c7b62c7",
14721            "localId should be preserved"
14722        );
14723    }
14724
14725    #[test]
14726    fn paragraph_localid_preserved_in_roundtrip() {
14727        // Issue #399: localId on paragraph nodes was dropped
14728        let adf_json = r#"{"version":1,"type":"doc","content":[
14729          {"type":"paragraph","attrs":{"localId":"abc-123"},"content":[{"type":"text","text":"hello"}]}
14730        ]}"#;
14731        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14732        let md = adf_to_markdown(&doc).unwrap();
14733        assert!(
14734            md.contains("localId=abc-123"),
14735            "JFM should contain localId, got: {md}"
14736        );
14737        let round_tripped = markdown_to_adf(&md).unwrap();
14738        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14739        assert_eq!(attrs["localId"], "abc-123", "localId should be preserved");
14740    }
14741
14742    #[test]
14743    fn heading_localid_preserved_in_roundtrip() {
14744        let adf_json = r#"{"version":1,"type":"doc","content":[
14745          {"type":"heading","attrs":{"level":2,"localId":"h-456"},"content":[{"type":"text","text":"Title"}]}
14746        ]}"#;
14747        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14748        let md = adf_to_markdown(&doc).unwrap();
14749        let round_tripped = markdown_to_adf(&md).unwrap();
14750        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14751        assert_eq!(attrs["localId"], "h-456");
14752    }
14753
14754    #[test]
14755    fn localid_with_alignment_preserved() {
14756        // localId and alignment marks should coexist in the same {attrs} block
14757        let adf_json = r#"{"version":1,"type":"doc","content":[
14758          {"type":"paragraph","attrs":{"localId":"p-789"},"marks":[{"type":"alignment","attrs":{"align":"center"}}],
14759           "content":[{"type":"text","text":"centered"}]}
14760        ]}"#;
14761        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14762        let md = adf_to_markdown(&doc).unwrap();
14763        assert!(md.contains("localId=p-789"), "should have localId: {md}");
14764        assert!(md.contains("align=center"), "should have align: {md}");
14765        let round_tripped = markdown_to_adf(&md).unwrap();
14766        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14767        assert_eq!(attrs["localId"], "p-789");
14768        let marks = round_tripped.content[0].marks.as_ref().unwrap();
14769        assert!(marks.iter().any(|m| m.mark_type == "alignment"));
14770    }
14771
14772    #[test]
14773    fn table_layout_default_preserved_in_roundtrip() {
14774        // Issue #380: layout='default' was elided
14775        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"}]}]}]}]}]}"#;
14776        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14777        let md = adf_to_markdown(&doc).unwrap();
14778        let round_tripped = markdown_to_adf(&md).unwrap();
14779        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14780        assert_eq!(
14781            attrs["layout"], "default",
14782            "layout='default' should be preserved"
14783        );
14784    }
14785
14786    #[test]
14787    fn table_is_number_column_enabled_false_preserved() {
14788        // Issue #380: isNumberColumnEnabled=false was elided
14789        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"}]}]}]}]}]}"#;
14790        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14791        let md = adf_to_markdown(&doc).unwrap();
14792        let round_tripped = markdown_to_adf(&md).unwrap();
14793        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14794        assert_eq!(
14795            attrs["isNumberColumnEnabled"], false,
14796            "isNumberColumnEnabled=false should be preserved"
14797        );
14798    }
14799
14800    #[test]
14801    fn table_is_number_column_enabled_true_preserved() {
14802        // Regression check: isNumberColumnEnabled=true should still work
14803        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"}]}]}]}]}]}"#;
14804        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14805        let md = adf_to_markdown(&doc).unwrap();
14806        let round_tripped = markdown_to_adf(&md).unwrap();
14807        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14808        assert_eq!(
14809            attrs["isNumberColumnEnabled"], true,
14810            "isNumberColumnEnabled=true should be preserved"
14811        );
14812    }
14813
14814    #[test]
14815    fn directive_table_is_number_column_enabled_false_preserved() {
14816        // Covers render_directive_table + directive table parsing for numbered=false.
14817        // Multi-paragraph cell forces directive table form.
14818        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[
14819          {"type":"paragraph","content":[{"type":"text","text":"line one"}]},
14820          {"type":"paragraph","content":[{"type":"text","text":"line two"}]}
14821        ]}]}]}]}"#;
14822        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14823        let md = adf_to_markdown(&doc).unwrap();
14824        assert!(md.contains("::::table"), "should use directive table form");
14825        assert!(
14826            md.contains("numbered=false"),
14827            "should contain numbered=false, got: {md}"
14828        );
14829        let round_tripped = markdown_to_adf(&md).unwrap();
14830        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14831        assert_eq!(attrs["isNumberColumnEnabled"], false);
14832        assert_eq!(attrs["layout"], "default");
14833    }
14834
14835    #[test]
14836    fn directive_table_is_number_column_enabled_true_preserved() {
14837        // Covers render_directive_table + directive table parsing for numbered (true).
14838        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":true,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[
14839          {"type":"paragraph","content":[{"type":"text","text":"line one"}]},
14840          {"type":"paragraph","content":[{"type":"text","text":"line two"}]}
14841        ]}]}]}]}"#;
14842        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14843        let md = adf_to_markdown(&doc).unwrap();
14844        assert!(md.contains("::::table"), "should use directive table form");
14845        assert!(
14846            md.contains("numbered}") || md.contains("numbered "),
14847            "should contain numbered flag, got: {md}"
14848        );
14849        let round_tripped = markdown_to_adf(&md).unwrap();
14850        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14851        assert_eq!(attrs["isNumberColumnEnabled"], true);
14852    }
14853
14854    #[test]
14855    fn trailing_space_in_bullet_list_item_preserved() {
14856        // Issue #394: trailing space text node in list item dropped
14857        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
14858          {"type":"listItem","content":[{"type":"paragraph","content":[
14859            {"type":"text","text":"Before link "},
14860            {"type":"text","text":"link text","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
14861            {"type":"text","text":" "}
14862          ]}]}
14863        ]}]}"#;
14864        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14865        let md = adf_to_markdown(&doc).unwrap();
14866        let round_tripped = markdown_to_adf(&md).unwrap();
14867        let list = &round_tripped.content[0];
14868        let item = &list.content.as_ref().unwrap()[0];
14869        let para = &item.content.as_ref().unwrap()[0];
14870        let inlines = para.content.as_ref().unwrap();
14871        let last = inlines.last().unwrap();
14872        assert_eq!(
14873            last.text.as_deref(),
14874            Some(" "),
14875            "trailing space text node should be preserved, got nodes: {:?}",
14876            inlines
14877                .iter()
14878                .map(|n| (&n.node_type, &n.text))
14879                .collect::<Vec<_>>()
14880        );
14881    }
14882
14883    #[test]
14884    fn trailing_space_after_mention_in_bullet_list_preserved() {
14885        // Mention + trailing space in list item
14886        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
14887          {"type":"listItem","content":[{"type":"paragraph","content":[
14888            {"type":"mention","attrs":{"id":"abc","text":"@Alice"}},
14889            {"type":"text","text":" "}
14890          ]}]}
14891        ]}]}"#;
14892        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14893        let md = adf_to_markdown(&doc).unwrap();
14894        let round_tripped = markdown_to_adf(&md).unwrap();
14895        let para = &round_tripped.content[0].content.as_ref().unwrap()[0]
14896            .content
14897            .as_ref()
14898            .unwrap()[0];
14899        let inlines = para.content.as_ref().unwrap();
14900        assert!(
14901            inlines.len() >= 2,
14902            "should have mention + trailing space, got {} nodes",
14903            inlines.len()
14904        );
14905        assert_eq!(inlines.last().unwrap().text.as_deref(), Some(" "));
14906    }
14907
14908    #[test]
14909    fn trailing_space_in_ordered_list_item_preserved() {
14910        // Same issue in ordered list context
14911        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
14912          {"type":"listItem","content":[{"type":"paragraph","content":[
14913            {"type":"text","text":"item "},
14914            {"type":"text","text":"link","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
14915            {"type":"text","text":" "}
14916          ]}]}
14917        ]}]}"#;
14918        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14919        let md = adf_to_markdown(&doc).unwrap();
14920        let round_tripped = markdown_to_adf(&md).unwrap();
14921        let para = &round_tripped.content[0].content.as_ref().unwrap()[0]
14922            .content
14923            .as_ref()
14924            .unwrap()[0];
14925        let inlines = para.content.as_ref().unwrap();
14926        let last = inlines.last().unwrap();
14927        assert_eq!(
14928            last.text.as_deref(),
14929            Some(" "),
14930            "trailing space should be preserved in ordered list item"
14931        );
14932    }
14933
14934    #[test]
14935    fn trailing_space_in_heading_text_preserved() {
14936        // Issue #400: trailing space in heading text node trimmed on round-trip
14937        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[
14938          {"type":"text","text":"Firefighting Engineers "}
14939        ]}]}"#;
14940        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14941        let md = adf_to_markdown(&doc).unwrap();
14942        let round_tripped = markdown_to_adf(&md).unwrap();
14943        let inlines = round_tripped.content[0].content.as_ref().unwrap();
14944        assert_eq!(
14945            inlines[0].text.as_deref(),
14946            Some("Firefighting Engineers "),
14947            "trailing space in heading should be preserved"
14948        );
14949    }
14950
14951    #[test]
14952    fn trailing_space_in_heading_before_bold_preserved() {
14953        // Issue #400: trailing space before bold sibling in heading
14954        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[
14955          {"type":"text","text":"Classic "},
14956          {"type":"text","text":"bold","marks":[{"type":"strong"}]}
14957        ]}]}"#;
14958        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14959        let md = adf_to_markdown(&doc).unwrap();
14960        let round_tripped = markdown_to_adf(&md).unwrap();
14961        let inlines = round_tripped.content[0].content.as_ref().unwrap();
14962        assert_eq!(
14963            inlines[0].text.as_deref(),
14964            Some("Classic "),
14965            "trailing space in heading text before bold should be preserved"
14966        );
14967    }
14968
14969    #[test]
14970    fn leading_space_in_heading_text_preserved() {
14971        // Issue #492: leading spaces in heading text node stripped on round-trip
14972        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":3},"content":[
14973          {"type":"text","text":"  #general-channel"}
14974        ]}]}"#;
14975        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14976        let md = adf_to_markdown(&doc).unwrap();
14977        let round_tripped = markdown_to_adf(&md).unwrap();
14978        let inlines = round_tripped.content[0].content.as_ref().unwrap();
14979        assert_eq!(
14980            inlines[0].text.as_deref(),
14981            Some("  #general-channel"),
14982            "leading spaces in heading text should be preserved"
14983        );
14984    }
14985
14986    #[test]
14987    fn leading_space_in_heading_before_bold_preserved() {
14988        // Issue #492: leading space before bold sibling in heading
14989        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[
14990          {"type":"text","text":"   indented"},
14991          {"type":"text","text":" bold","marks":[{"type":"strong"}]}
14992        ]}]}"#;
14993        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14994        let md = adf_to_markdown(&doc).unwrap();
14995        let round_tripped = markdown_to_adf(&md).unwrap();
14996        let inlines = round_tripped.content[0].content.as_ref().unwrap();
14997        assert_eq!(
14998            inlines[0].text.as_deref(),
14999            Some("   indented"),
15000            "leading spaces in heading text before bold should be preserved"
15001        );
15002    }
15003
15004    #[test]
15005    fn heading_multiple_leading_spaces_markdown_parse() {
15006        // Issue #492: verify JFM parsing preserves leading spaces
15007        let md = "### \t  #general-channel";
15008        let doc = markdown_to_adf(md).unwrap();
15009        let inlines = doc.content[0].content.as_ref().unwrap();
15010        assert_eq!(
15011            inlines[0].text.as_deref(),
15012            Some("\t  #general-channel"),
15013            "leading whitespace in heading text should be preserved during JFM parsing"
15014        );
15015    }
15016
15017    #[test]
15018    fn trailing_space_in_paragraph_text_preserved() {
15019        // Issue #400: trailing space in paragraph text node preserved on round-trip
15020        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
15021          {"type":"text","text":"word followed by space "},
15022          {"type":"text","text":"next node","marks":[{"type":"strong"}]}
15023        ]}]}"#;
15024        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15025        let md = adf_to_markdown(&doc).unwrap();
15026        let round_tripped = markdown_to_adf(&md).unwrap();
15027        let inlines = round_tripped.content[0].content.as_ref().unwrap();
15028        assert_eq!(
15029            inlines[0].text.as_deref(),
15030            Some("word followed by space "),
15031            "trailing space in paragraph text should be preserved"
15032        );
15033    }
15034
15035    #[test]
15036    fn nested_bullet_list_roundtrip() {
15037        // ADF with a listItem containing a paragraph + nested bulletList
15038        let adf_doc = serde_json::json!({
15039            "type": "doc",
15040            "version": 1,
15041            "content": [{
15042                "type": "bulletList",
15043                "content": [{
15044                    "type": "listItem",
15045                    "content": [
15046                        {
15047                            "type": "paragraph",
15048                            "content": [{"type": "text", "text": "parent item"}]
15049                        },
15050                        {
15051                            "type": "bulletList",
15052                            "content": [
15053                                {
15054                                    "type": "listItem",
15055                                    "content": [{
15056                                        "type": "paragraph",
15057                                        "content": [{"type": "text", "text": "sub item 1"}]
15058                                    }]
15059                                },
15060                                {
15061                                    "type": "listItem",
15062                                    "content": [{
15063                                        "type": "paragraph",
15064                                        "content": [{"type": "text", "text": "sub item 2"}]
15065                                    }]
15066                                }
15067                            ]
15068                        }
15069                    ]
15070                }]
15071            }]
15072        });
15073        let doc: AdfDocument = serde_json::from_value(adf_doc).unwrap();
15074        let md = adf_to_markdown(&doc).unwrap();
15075        assert!(
15076            md.contains("- parent item\n"),
15077            "expected top-level item in markdown, got: {md}"
15078        );
15079        assert!(
15080            md.contains("  - sub item 1\n"),
15081            "expected indented sub item 1 in markdown, got: {md}"
15082        );
15083        assert!(
15084            md.contains("  - sub item 2\n"),
15085            "expected indented sub item 2 in markdown, got: {md}"
15086        );
15087
15088        // Round-trip back
15089        let doc2 = markdown_to_adf(&md).unwrap();
15090        let list = &doc2.content[0];
15091        assert_eq!(list.node_type, "bulletList");
15092        let item = &list.content.as_ref().unwrap()[0];
15093        assert_eq!(item.node_type, "listItem");
15094        let item_content = item.content.as_ref().unwrap();
15095        assert_eq!(
15096            item_content.len(),
15097            2,
15098            "listItem should have paragraph + nested list"
15099        );
15100        assert_eq!(item_content[0].node_type, "paragraph");
15101        assert_eq!(item_content[1].node_type, "bulletList");
15102        let sub_items = item_content[1].content.as_ref().unwrap();
15103        assert_eq!(sub_items.len(), 2);
15104    }
15105
15106    #[test]
15107    fn nested_bullet_in_table_cell_roundtrip() {
15108        let md = "::::table\n:::tr\n:::td\n- parent\n  - child\n:::\n:::\n::::\n";
15109        let doc = markdown_to_adf(md).unwrap();
15110        let table = &doc.content[0];
15111        let row = &table.content.as_ref().unwrap()[0];
15112        let cell = &row.content.as_ref().unwrap()[0];
15113        let list = &cell.content.as_ref().unwrap()[0];
15114        assert_eq!(list.node_type, "bulletList");
15115        let item = &list.content.as_ref().unwrap()[0];
15116        let item_content = item.content.as_ref().unwrap();
15117        assert_eq!(
15118            item_content.len(),
15119            2,
15120            "listItem should have paragraph + nested list"
15121        );
15122        assert_eq!(item_content[1].node_type, "bulletList");
15123
15124        // Round-trip: adf→md→adf should preserve the nested list
15125        let md2 = adf_to_markdown(&doc).unwrap();
15126        assert!(
15127            md2.contains("  - child"),
15128            "expected indented child in round-tripped markdown, got: {md2}"
15129        );
15130    }
15131
15132    #[test]
15133    fn nested_ordered_list_roundtrip() {
15134        // Issue #389: nested orderedList inside listItem flattened
15135        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
15136          {"type":"listItem","content":[
15137            {"type":"paragraph","content":[{"type":"text","text":"Top level"}]},
15138            {"type":"orderedList","attrs":{"order":1},"content":[
15139              {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Nested 1"}]}]},
15140              {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Nested 2"}]}]}
15141            ]}
15142          ]},
15143          {"type":"listItem","content":[
15144            {"type":"paragraph","content":[{"type":"text","text":"Second top"}]}
15145          ]}
15146        ]}]}"#;
15147        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15148        let md = adf_to_markdown(&doc).unwrap();
15149        let round_tripped = markdown_to_adf(&md).unwrap();
15150
15151        // Outer list should have 2 items
15152        let outer = &round_tripped.content[0];
15153        assert_eq!(outer.node_type, "orderedList");
15154        assert_eq!(
15155            outer.attrs.as_ref().unwrap()["order"],
15156            1,
15157            "explicit order=1 must be preserved via trailing {{order=1}} (issue #547)"
15158        );
15159        let outer_items = outer.content.as_ref().unwrap();
15160        assert_eq!(
15161            outer_items.len(),
15162            2,
15163            "outer list should have 2 items, got {}",
15164            outer_items.len()
15165        );
15166
15167        // First item should have paragraph + nested orderedList
15168        let first_item = &outer_items[0];
15169        let first_content = first_item.content.as_ref().unwrap();
15170        assert_eq!(
15171            first_content.len(),
15172            2,
15173            "first listItem should have paragraph + nested list, got {}",
15174            first_content.len()
15175        );
15176        assert_eq!(first_content[0].node_type, "paragraph");
15177        assert_eq!(first_content[1].node_type, "orderedList");
15178        let nested_items = first_content[1].content.as_ref().unwrap();
15179        assert_eq!(nested_items.len(), 2, "nested list should have 2 items");
15180    }
15181
15182    #[test]
15183    fn nested_ordered_list_markdown_parsing() {
15184        // Direct markdown parsing of nested ordered list
15185        let md = "1. Top level\n  1. Nested 1\n  2. Nested 2\n2. Second top\n";
15186        let doc = markdown_to_adf(md).unwrap();
15187        let outer = &doc.content[0];
15188        assert_eq!(outer.node_type, "orderedList");
15189        let outer_items = outer.content.as_ref().unwrap();
15190        assert_eq!(outer_items.len(), 2, "should have 2 top-level items");
15191
15192        let first_content = outer_items[0].content.as_ref().unwrap();
15193        assert_eq!(
15194            first_content.len(),
15195            2,
15196            "first item should have paragraph + nested list"
15197        );
15198        assert_eq!(first_content[1].node_type, "orderedList");
15199    }
15200
15201    #[test]
15202    fn bullet_list_nested_inside_ordered_list() {
15203        // Mixed nesting: bullet list nested inside ordered list
15204        let md = "1. Ordered item\n  - Bullet child 1\n  - Bullet child 2\n2. Second ordered\n";
15205        let doc = markdown_to_adf(md).unwrap();
15206        let outer = &doc.content[0];
15207        assert_eq!(outer.node_type, "orderedList");
15208        let outer_items = outer.content.as_ref().unwrap();
15209        assert_eq!(outer_items.len(), 2);
15210
15211        let first_content = outer_items[0].content.as_ref().unwrap();
15212        assert_eq!(
15213            first_content.len(),
15214            2,
15215            "first item should have paragraph + nested list"
15216        );
15217        assert_eq!(first_content[1].node_type, "bulletList");
15218        let sub_items = first_content[1].content.as_ref().unwrap();
15219        assert_eq!(sub_items.len(), 2, "nested bullet list should have 2 items");
15220    }
15221
15222    #[test]
15223    fn ordered_list_order_attr_one_is_elided() {
15224        // Issue #547: order=1 is the default and must be elided from attrs
15225        // for round-trip fidelity with ADF documents that omit the attrs
15226        // object on orderedList.
15227        let md = "1. A\n2. B\n";
15228        let doc = markdown_to_adf(md).unwrap();
15229        assert!(
15230            doc.content[0].attrs.is_none(),
15231            "attrs should be elided when order=1"
15232        );
15233
15234        // Round-trip should preserve the elision
15235        let md2 = adf_to_markdown(&doc).unwrap();
15236        let doc2 = markdown_to_adf(&md2).unwrap();
15237        assert!(
15238            doc2.content[0].attrs.is_none(),
15239            "attrs should remain elided after round-trip"
15240        );
15241    }
15242
15243    #[test]
15244    fn issue_547_ordered_list_no_attrs_roundtrip_byte_identical() {
15245        // Issue #547: ADF orderedList without an attrs field must round-trip
15246        // (ADF → JFM → ADF) without gaining a spurious {"order": 1} attrs.
15247        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"First item"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Second item"}]}]}]}]}"#;
15248        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15249        let md = adf_to_markdown(&doc).unwrap();
15250        let rt = markdown_to_adf(&md).unwrap();
15251        assert!(
15252            rt.content[0].attrs.is_none(),
15253            "round-tripped orderedList should not have attrs, got: {:?}",
15254            rt.content[0].attrs
15255        );
15256
15257        // Serialized JSON must also omit attrs entirely for byte fidelity.
15258        let rt_json = serde_json::to_string(&rt).unwrap();
15259        assert!(
15260            !rt_json.contains("\"order\""),
15261            "round-tripped JSON should not contain \"order\", got: {rt_json}"
15262        );
15263    }
15264
15265    // ── Issue #547: orderedList byte-identical roundtrip coverage ───────
15266
15267    /// Assert that ADF → JFM → ADF produces a document whose serialized JSON
15268    /// (as a sorted-key canonical form) matches the source JSON. Mirrors the
15269    /// `jq --sort-keys` comparison used in the issue's reproducer.
15270    fn assert_roundtrip_byte_identical(adf_json: &str) {
15271        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15272        let md = adf_to_markdown(&doc).unwrap();
15273        let rt = markdown_to_adf(&md).unwrap();
15274
15275        let canonical_src: serde_json::Value = serde_json::from_str(adf_json).unwrap();
15276        let canonical_rt: serde_json::Value =
15277            serde_json::from_str(&serde_json::to_string(&rt).unwrap()).unwrap();
15278        assert_eq!(
15279            canonical_src, canonical_rt,
15280            "round-trip diverged\n  src: {canonical_src}\n   rt: {canonical_rt}\n   md: {md:?}"
15281        );
15282    }
15283
15284    #[test]
15285    fn issue_547_single_item_no_attrs_roundtrip() {
15286        assert_roundtrip_byte_identical(
15287            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"only"}]}]}]}]}"#,
15288        );
15289    }
15290
15291    #[test]
15292    fn issue_547_many_items_no_attrs_roundtrip() {
15293        assert_roundtrip_byte_identical(
15294            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"A"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"B"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"C"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"D"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"E"}]}]}]}]}"#,
15295        );
15296    }
15297
15298    #[test]
15299    fn issue_547_non_default_order_preserved() {
15300        // When order != 1, attrs must still be serialized (fix must not
15301        // over-eagerly drop attrs).
15302        assert_roundtrip_byte_identical(
15303            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":5},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"fifth"}]}]}]}]}"#,
15304        );
15305    }
15306
15307    #[test]
15308    fn issue_547_nested_ordered_in_ordered_no_attrs_roundtrip() {
15309        // Outer and inner both omit attrs; fix must apply at every level.
15310        assert_roundtrip_byte_identical(
15311            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"outer"}]},{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"inner"}]}]}]}]}]}]}"#,
15312        );
15313    }
15314
15315    #[test]
15316    fn issue_547_ordered_nested_in_bullet_no_attrs_roundtrip() {
15317        assert_roundtrip_byte_identical(
15318            r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"bullet"}]},{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"nested"}]}]}]}]}]}]}"#,
15319        );
15320    }
15321
15322    #[test]
15323    fn issue_547_bullet_nested_in_ordered_no_attrs_roundtrip() {
15324        assert_roundtrip_byte_identical(
15325            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"outer"}]},{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"nested"}]}]}]}]}]}]}"#,
15326        );
15327    }
15328
15329    #[test]
15330    fn issue_547_ordered_list_between_paragraphs_roundtrip() {
15331        assert_roundtrip_byte_identical(
15332            r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"intro"}]},{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"item"}]}]}]},{"type":"paragraph","content":[{"type":"text","text":"outro"}]}]}"#,
15333        );
15334    }
15335
15336    #[test]
15337    fn issue_547_ordered_list_with_marked_text_roundtrip() {
15338        assert_roundtrip_byte_identical(
15339            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"bold","marks":[{"type":"strong"}]}]}]}]}]}"#,
15340        );
15341    }
15342
15343    #[test]
15344    fn issue_547_ordered_list_with_link_roundtrip() {
15345        assert_roundtrip_byte_identical(
15346            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"site","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]}]}]}]}]}"#,
15347        );
15348    }
15349
15350    #[test]
15351    fn issue_547_ordered_list_with_hardbreak_roundtrip() {
15352        assert_roundtrip_byte_identical(
15353            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a"},{"type":"hardBreak"},{"type":"text","text":"b"}]}]}]}]}"#,
15354        );
15355    }
15356
15357    #[test]
15358    fn issue_547_triple_nested_ordered_roundtrip() {
15359        assert_roundtrip_byte_identical(
15360            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"L1"}]},{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"L2"}]},{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"L3"}]}]}]}]}]}]}]}]}"#,
15361        );
15362    }
15363
15364    #[test]
15365    fn issue_547_ordered_list_heading_rule_mix_roundtrip() {
15366        assert_roundtrip_byte_identical(
15367            r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"Title"}]},{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]},{"type":"rule"}]}"#,
15368        );
15369    }
15370
15371    #[test]
15372    fn issue_547_ordered_list_listitem_localid_roundtrip() {
15373        // listItem attrs must coexist with the no-attrs outer orderedList.
15374        assert_roundtrip_byte_identical(
15375            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","attrs":{"localId":"li-001"},"content":[{"type":"paragraph","content":[{"type":"text","text":"first"}]}]}]}]}"#,
15376        );
15377    }
15378
15379    #[test]
15380    fn issue_547_explicit_order_one_preserved_roundtrip() {
15381        // Inverse regression (see PR #562 comment 4266630848): when the source
15382        // ADF has an explicit `"attrs": {"order": 1}` the round-trip must
15383        // preserve it, not strip it. A trailing `{order=1}` signal on the
15384        // rendered markdown distinguishes explicit-default from omitted attrs.
15385        assert_roundtrip_byte_identical(
15386            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"First item"}]}]}]}]}"#,
15387        );
15388    }
15389
15390    #[test]
15391    fn issue_547_explicit_order_one_nested_preserved_roundtrip() {
15392        // Both outer and inner orderedList have explicit `order: 1`; both must
15393        // be preserved across the round-trip independently.
15394        assert_roundtrip_byte_identical(
15395            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"outer"}]},{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"inner"}]}]}]}]}]}]}"#,
15396        );
15397    }
15398
15399    #[test]
15400    fn issue_547_mixed_explicit_and_implicit_order_roundtrip() {
15401        // Sibling orderedLists with different attrs presence must round-trip
15402        // independently: first has explicit `order: 1`, second omits attrs.
15403        assert_roundtrip_byte_identical(
15404            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a"}]}]}]},{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"b"}]}]}]}]}"#,
15405        );
15406    }
15407
15408    #[test]
15409    fn issue_547_explicit_order_one_with_listitem_localid_roundtrip() {
15410        // Explicit `order: 1` outer, plus a listItem `localId` inside — the
15411        // trailing `{order=1}` line must not swallow or collide with listItem
15412        // attrs.
15413        assert_roundtrip_byte_identical(
15414            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","attrs":{"localId":"li-1"},"content":[{"type":"paragraph","content":[{"type":"text","text":"first"}]}]}]}]}"#,
15415        );
15416    }
15417
15418    #[test]
15419    fn issue_547_order_attr_signal_appears_only_for_explicit_one() {
15420        // Render-layer guard: `{order=1}` appears in markdown only when the
15421        // source ADF has explicit `attrs.order=1`. No signal for attrs=None,
15422        // no signal for attrs.order>1 (marker already encodes the value).
15423        let no_attrs = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]}]}"#;
15424        let explicit_one = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]}]}"#;
15425        let order_five = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":5},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]}]}"#;
15426
15427        let md_no =
15428            adf_to_markdown(&serde_json::from_str::<AdfDocument>(no_attrs).unwrap()).unwrap();
15429        let md_one =
15430            adf_to_markdown(&serde_json::from_str::<AdfDocument>(explicit_one).unwrap()).unwrap();
15431        let md_five =
15432            adf_to_markdown(&serde_json::from_str::<AdfDocument>(order_five).unwrap()).unwrap();
15433
15434        assert!(
15435            !md_no.contains("{order="),
15436            "no-attrs source must not emit order signal, got: {md_no:?}"
15437        );
15438        assert!(
15439            md_one.contains("{order=1}"),
15440            "explicit order=1 must emit trailing signal, got: {md_one:?}"
15441        );
15442        assert!(
15443            !md_five.contains("{order="),
15444            "order=5 is already encoded by marker; must not emit signal, got: {md_five:?}"
15445        );
15446    }
15447
15448    // ── File media round-trip tests ─────────────────────────────────────
15449
15450    #[test]
15451    fn file_media_roundtrip() {
15452        // ADF with a Confluence file attachment (type:file media)
15453        let adf_doc = serde_json::json!({
15454            "type": "doc",
15455            "version": 1,
15456            "content": [{
15457                "type": "mediaSingle",
15458                "attrs": {"layout": "center"},
15459                "content": [{
15460                    "type": "media",
15461                    "attrs": {
15462                        "type": "file",
15463                        "id": "6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d",
15464                        "collection": "contentId-8220672100",
15465                        "height": 56,
15466                        "width": 312,
15467                        "alt": "Screenshot.png"
15468                    }
15469                }]
15470            }]
15471        });
15472        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
15473        let md = adf_to_markdown(&doc).unwrap();
15474        assert!(
15475            md.contains("type=file"),
15476            "expected type=file in markdown, got: {md}"
15477        );
15478        assert!(
15479            md.contains("id=6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d"),
15480            "expected id in markdown, got: {md}"
15481        );
15482        assert!(
15483            md.contains("collection=contentId-8220672100"),
15484            "expected collection in markdown, got: {md}"
15485        );
15486        // Round-trip back to ADF
15487        let doc2 = markdown_to_adf(&md).unwrap();
15488        let ms = &doc2.content[0];
15489        assert_eq!(ms.node_type, "mediaSingle");
15490        let media = &ms.content.as_ref().unwrap()[0];
15491        assert_eq!(media.node_type, "media");
15492        let attrs = media.attrs.as_ref().unwrap();
15493        assert_eq!(attrs["type"], "file");
15494        assert_eq!(attrs["id"], "6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d");
15495        assert_eq!(attrs["collection"], "contentId-8220672100");
15496        assert_eq!(attrs["height"], 56);
15497        assert_eq!(attrs["width"], 312);
15498        assert_eq!(attrs["alt"], "Screenshot.png");
15499    }
15500
15501    /// Issue #550: roundtrip of mediaSingle with file-type media preserves all
15502    /// file attributes (type, id, collection, width, height). Regression guard
15503    /// for the exact reproducer in the issue body.
15504    #[test]
15505    fn file_media_roundtrip_issue_550_reproducer() {
15506        let adf_json = r#"{
15507          "version": 1,
15508          "type": "doc",
15509          "content": [
15510            {
15511              "type": "mediaSingle",
15512              "attrs": {"layout": "center"},
15513              "content": [
15514                {
15515                  "type": "media",
15516                  "attrs": {
15517                    "type": "file",
15518                    "id": "abc-123-def-456",
15519                    "collection": "my-collection",
15520                    "width": 941,
15521                    "height": 655
15522                  }
15523                }
15524              ]
15525            }
15526          ]
15527        }"#;
15528        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15529        let md = adf_to_markdown(&doc).unwrap();
15530        let rt = markdown_to_adf(&md).unwrap();
15531        let expected: serde_json::Value = serde_json::from_str(adf_json).unwrap();
15532        let actual = serde_json::to_value(&rt).unwrap();
15533        assert_eq!(
15534            actual, expected,
15535            "roundtrip should preserve file media attrs; md was:\n{md}"
15536        );
15537    }
15538
15539    /// Issue #550 (updated reproducer): roundtrip of a file-media `id`
15540    /// containing spaces must not truncate the value. Before the fix, the
15541    /// JFM renderer emitted `id=abc 123 def 456` unquoted and the parser
15542    /// treated the first space as a value terminator, so the `id` became
15543    /// `"abc"` after round-trip.
15544    #[test]
15545    fn file_media_roundtrip_id_with_spaces() {
15546        let adf_json = r#"{
15547          "version": 1,
15548          "type": "doc",
15549          "content": [
15550            {
15551              "type": "mediaSingle",
15552              "attrs": {"layout": "center"},
15553              "content": [
15554                {
15555                  "type": "media",
15556                  "attrs": {
15557                    "type": "file",
15558                    "id": "abc 123 def 456",
15559                    "collection": "my-collection",
15560                    "width": 800,
15561                    "height": 600
15562                  }
15563                }
15564              ]
15565            }
15566          ]
15567        }"#;
15568        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15569        let md = adf_to_markdown(&doc).unwrap();
15570        assert!(
15571            md.contains(r#"id="abc 123 def 456""#),
15572            "id with spaces should be quoted in JFM, got:\n{md}"
15573        );
15574        let rt = markdown_to_adf(&md).unwrap();
15575        let expected: serde_json::Value = serde_json::from_str(adf_json).unwrap();
15576        let actual = serde_json::to_value(&rt).unwrap();
15577        assert_eq!(
15578            actual, expected,
15579            "space-containing id must round-trip; md was:\n{md}"
15580        );
15581    }
15582
15583    /// Space-containing `collection` values must round-trip.
15584    #[test]
15585    fn file_media_roundtrip_collection_with_spaces() {
15586        let adf_json = r#"{
15587          "version": 1,
15588          "type": "doc",
15589          "content": [
15590            {
15591              "type": "mediaSingle",
15592              "attrs": {"layout": "center"},
15593              "content": [
15594                {
15595                  "type": "media",
15596                  "attrs": {
15597                    "type": "file",
15598                    "id": "abc-123",
15599                    "collection": "my collection with spaces"
15600                  }
15601                }
15602              ]
15603            }
15604          ]
15605        }"#;
15606        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15607        let md = adf_to_markdown(&doc).unwrap();
15608        let rt = markdown_to_adf(&md).unwrap();
15609        let media = &rt.content[0].content.as_ref().unwrap()[0];
15610        assert_eq!(
15611            media.attrs.as_ref().unwrap()["collection"],
15612            "my collection with spaces"
15613        );
15614    }
15615
15616    /// Space-containing `occurrenceKey` values must round-trip.
15617    #[test]
15618    fn file_media_roundtrip_occurrence_key_with_spaces() {
15619        let adf_json = r#"{
15620          "version": 1,
15621          "type": "doc",
15622          "content": [
15623            {
15624              "type": "mediaSingle",
15625              "attrs": {"layout": "center"},
15626              "content": [
15627                {
15628                  "type": "media",
15629                  "attrs": {
15630                    "type": "file",
15631                    "id": "x",
15632                    "collection": "y",
15633                    "occurrenceKey": "key with spaces"
15634                  }
15635                }
15636              ]
15637            }
15638          ]
15639        }"#;
15640        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15641        let md = adf_to_markdown(&doc).unwrap();
15642        let rt = markdown_to_adf(&md).unwrap();
15643        let media = &rt.content[0].content.as_ref().unwrap()[0];
15644        assert_eq!(
15645            media.attrs.as_ref().unwrap()["occurrenceKey"],
15646            "key with spaces"
15647        );
15648    }
15649
15650    /// Values with embedded `"` must be escape-quoted and round-trip.
15651    #[test]
15652    fn file_media_roundtrip_id_with_quote_char() {
15653        let adf_json = r#"{
15654          "version": 1,
15655          "type": "doc",
15656          "content": [
15657            {
15658              "type": "mediaSingle",
15659              "attrs": {"layout": "center"},
15660              "content": [
15661                {
15662                  "type": "media",
15663                  "attrs": {
15664                    "type": "file",
15665                    "id": "a\"b\"c",
15666                    "collection": "col"
15667                  }
15668                }
15669              ]
15670            }
15671          ]
15672        }"#;
15673        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15674        let md = adf_to_markdown(&doc).unwrap();
15675        let rt = markdown_to_adf(&md).unwrap();
15676        let media = &rt.content[0].content.as_ref().unwrap()[0];
15677        assert_eq!(media.attrs.as_ref().unwrap()["id"], "a\"b\"c");
15678    }
15679
15680    /// `mediaInline` string attrs with spaces must round-trip (parallel fix
15681    /// for the inline-directive rendering path).
15682    #[test]
15683    fn media_inline_roundtrip_id_with_spaces() {
15684        let adf_json = r#"{
15685          "version": 1,
15686          "type": "doc",
15687          "content": [
15688            {
15689              "type": "paragraph",
15690              "content": [
15691                {"type": "text", "text": "before "},
15692                {
15693                  "type": "mediaInline",
15694                  "attrs": {
15695                    "type": "file",
15696                    "id": "a b c",
15697                    "collection": "my col"
15698                  }
15699                },
15700                {"type": "text", "text": " after"}
15701              ]
15702            }
15703          ]
15704        }"#;
15705        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15706        let md = adf_to_markdown(&doc).unwrap();
15707        let rt = markdown_to_adf(&md).unwrap();
15708        let inline = &rt.content[0].content.as_ref().unwrap()[1];
15709        assert_eq!(inline.node_type, "mediaInline");
15710        let attrs = inline.attrs.as_ref().unwrap();
15711        assert_eq!(attrs["id"], "a b c");
15712        assert_eq!(attrs["collection"], "my col");
15713    }
15714
15715    /// Issue #550: `occurrenceKey` attribute is a standard ADF media attr and
15716    /// must be preserved through ADF→JFM→ADF roundtrip.
15717    #[test]
15718    fn file_media_roundtrip_preserves_occurrence_key() {
15719        let adf_json = r#"{
15720          "version": 1,
15721          "type": "doc",
15722          "content": [
15723            {
15724              "type": "mediaSingle",
15725              "attrs": {"layout": "center"},
15726              "content": [
15727                {
15728                  "type": "media",
15729                  "attrs": {
15730                    "type": "file",
15731                    "id": "abc-123",
15732                    "collection": "my-collection",
15733                    "occurrenceKey": "unique-key-xyz",
15734                    "width": 200,
15735                    "height": 100
15736                  }
15737                }
15738              ]
15739            }
15740          ]
15741        }"#;
15742        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15743        let md = adf_to_markdown(&doc).unwrap();
15744        assert!(
15745            md.contains("occurrenceKey=unique-key-xyz"),
15746            "expected occurrenceKey in markdown, got: {md}"
15747        );
15748        let rt = markdown_to_adf(&md).unwrap();
15749        let media = &rt.content[0].content.as_ref().unwrap()[0];
15750        let attrs = media.attrs.as_ref().unwrap();
15751        assert_eq!(attrs["occurrenceKey"], "unique-key-xyz");
15752        assert_eq!(attrs["type"], "file");
15753        assert_eq!(attrs["id"], "abc-123");
15754        assert_eq!(attrs["collection"], "my-collection");
15755    }
15756
15757    // ── mediaSingle caption tests (issue #470) ──────────────────────────
15758
15759    #[test]
15760    fn media_single_caption_adf_to_markdown() {
15761        let adf_doc = serde_json::json!({
15762            "type": "doc",
15763            "version": 1,
15764            "content": [{
15765                "type": "mediaSingle",
15766                "attrs": {"layout": "center", "width": 400, "widthType": "pixel"},
15767                "content": [
15768                    {
15769                        "type": "media",
15770                        "attrs": {
15771                            "id": "aabbccdd-1234-5678-abcd-aabbccdd1234",
15772                            "type": "file",
15773                            "collection": "contentId-123456",
15774                            "width": 800,
15775                            "height": 600
15776                        }
15777                    },
15778                    {
15779                        "type": "caption",
15780                        "content": [{"type": "text", "text": "An image caption here"}]
15781                    }
15782                ]
15783            }]
15784        });
15785        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
15786        let md = adf_to_markdown(&doc).unwrap();
15787        assert!(
15788            md.contains(":::caption"),
15789            "expected :::caption in markdown, got: {md}"
15790        );
15791        assert!(
15792            md.contains("An image caption here"),
15793            "expected caption text in markdown, got: {md}"
15794        );
15795    }
15796
15797    #[test]
15798    fn media_single_caption_markdown_to_adf() {
15799        let md = "![Screenshot](){type=file id=abc-123 collection=contentId-456 height=600 width=800}\n:::caption\nAn image caption here\n:::\n";
15800        let doc = markdown_to_adf(md).unwrap();
15801        let ms = &doc.content[0];
15802        assert_eq!(ms.node_type, "mediaSingle");
15803        let content = ms.content.as_ref().unwrap();
15804        assert_eq!(content.len(), 2, "expected media + caption children");
15805        assert_eq!(content[0].node_type, "media");
15806        assert_eq!(content[1].node_type, "caption");
15807        let caption_content = content[1].content.as_ref().unwrap();
15808        assert_eq!(
15809            caption_content[0].text.as_deref(),
15810            Some("An image caption here")
15811        );
15812    }
15813
15814    #[test]
15815    fn media_single_caption_round_trip() {
15816        // Full round-trip: ADF → JFM → ADF preserves caption
15817        let adf_doc = serde_json::json!({
15818            "type": "doc",
15819            "version": 1,
15820            "content": [{
15821                "type": "mediaSingle",
15822                "attrs": {"layout": "center", "width": 400, "widthType": "pixel"},
15823                "content": [
15824                    {
15825                        "type": "media",
15826                        "attrs": {
15827                            "id": "aabbccdd-1234-5678-abcd-aabbccdd1234",
15828                            "type": "file",
15829                            "collection": "contentId-123456",
15830                            "width": 800,
15831                            "height": 600
15832                        }
15833                    },
15834                    {
15835                        "type": "caption",
15836                        "content": [{"type": "text", "text": "An image caption here"}]
15837                    }
15838                ]
15839            }]
15840        });
15841        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
15842        let md = adf_to_markdown(&doc).unwrap();
15843        let doc2 = markdown_to_adf(&md).unwrap();
15844        let ms = &doc2.content[0];
15845        assert_eq!(ms.node_type, "mediaSingle");
15846        let content = ms.content.as_ref().unwrap();
15847        assert_eq!(
15848            content.len(),
15849            2,
15850            "expected media + caption after round-trip"
15851        );
15852        assert_eq!(content[1].node_type, "caption");
15853        let caption_content = content[1].content.as_ref().unwrap();
15854        assert_eq!(
15855            caption_content[0].text.as_deref(),
15856            Some("An image caption here")
15857        );
15858    }
15859
15860    #[test]
15861    fn media_single_caption_with_inline_marks() {
15862        let adf_doc = serde_json::json!({
15863            "type": "doc",
15864            "version": 1,
15865            "content": [{
15866                "type": "mediaSingle",
15867                "attrs": {"layout": "center"},
15868                "content": [
15869                    {
15870                        "type": "media",
15871                        "attrs": {"type": "external", "url": "https://example.com/img.png"}
15872                    },
15873                    {
15874                        "type": "caption",
15875                        "content": [
15876                            {"type": "text", "text": "A "},
15877                            {"type": "text", "text": "bold", "marks": [{"type": "strong"}]},
15878                            {"type": "text", "text": " caption"}
15879                        ]
15880                    }
15881                ]
15882            }]
15883        });
15884        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
15885        let md = adf_to_markdown(&doc).unwrap();
15886        assert!(
15887            md.contains("**bold**"),
15888            "expected bold in caption, got: {md}"
15889        );
15890
15891        let doc2 = markdown_to_adf(&md).unwrap();
15892        let content = doc2.content[0].content.as_ref().unwrap();
15893        assert_eq!(content.len(), 2, "expected media + caption");
15894        assert_eq!(content[1].node_type, "caption");
15895        let caption_inlines = content[1].content.as_ref().unwrap();
15896        let bold_node = caption_inlines
15897            .iter()
15898            .find(|n| n.text.as_deref() == Some("bold"))
15899            .unwrap();
15900        let marks = bold_node.marks.as_ref().unwrap();
15901        assert_eq!(marks[0].mark_type, "strong");
15902    }
15903
15904    #[test]
15905    fn media_single_no_caption_unaffected() {
15906        // Existing mediaSingle without caption should be unaffected
15907        let adf_doc = serde_json::json!({
15908            "type": "doc",
15909            "version": 1,
15910            "content": [{
15911                "type": "mediaSingle",
15912                "attrs": {"layout": "center"},
15913                "content": [{
15914                    "type": "media",
15915                    "attrs": {"type": "external", "url": "https://example.com/img.png"}
15916                }]
15917            }]
15918        });
15919        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
15920        let md = adf_to_markdown(&doc).unwrap();
15921        assert!(
15922            !md.contains(":::caption"),
15923            "should not emit caption when none present"
15924        );
15925        let doc2 = markdown_to_adf(&md).unwrap();
15926        let content = doc2.content[0].content.as_ref().unwrap();
15927        assert_eq!(content.len(), 1, "should only have media child");
15928        assert_eq!(content[0].node_type, "media");
15929    }
15930
15931    #[test]
15932    fn media_single_empty_caption_round_trip() {
15933        // Caption node with no content should still round-trip
15934        let adf_doc = serde_json::json!({
15935            "type": "doc",
15936            "version": 1,
15937            "content": [{
15938                "type": "mediaSingle",
15939                "attrs": {"layout": "center"},
15940                "content": [
15941                    {
15942                        "type": "media",
15943                        "attrs": {"type": "external", "url": "https://example.com/img.png"}
15944                    },
15945                    {
15946                        "type": "caption"
15947                    }
15948                ]
15949            }]
15950        });
15951        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
15952        let md = adf_to_markdown(&doc).unwrap();
15953        assert!(
15954            md.contains(":::caption"),
15955            "expected :::caption even for empty caption, got: {md}"
15956        );
15957        assert!(
15958            md.contains(":::\n"),
15959            "expected closing ::: fence, got: {md}"
15960        );
15961    }
15962
15963    #[test]
15964    fn media_single_external_caption_round_trip() {
15965        // External image with caption round-trips
15966        let md = "![alt](https://example.com/img.png)\n:::caption\nImage description\n:::\n";
15967        let doc = markdown_to_adf(md).unwrap();
15968        let ms = &doc.content[0];
15969        assert_eq!(ms.node_type, "mediaSingle");
15970        let content = ms.content.as_ref().unwrap();
15971        assert_eq!(content.len(), 2);
15972        assert_eq!(content[0].node_type, "media");
15973        assert_eq!(content[1].node_type, "caption");
15974
15975        let md2 = adf_to_markdown(&doc).unwrap();
15976        let doc2 = markdown_to_adf(&md2).unwrap();
15977        let content2 = doc2.content[0].content.as_ref().unwrap();
15978        assert_eq!(content2.len(), 2);
15979        assert_eq!(content2[1].node_type, "caption");
15980        let caption_text = content2[1].content.as_ref().unwrap();
15981        assert_eq!(caption_text[0].text.as_deref(), Some("Image description"));
15982    }
15983
15984    // ── mediaSingle caption localId tests (issue #524) ─────────────────
15985
15986    #[test]
15987    fn media_single_caption_localid_roundtrip() {
15988        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"}]}]}]}"#;
15989        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15990        let md = adf_to_markdown(&doc).unwrap();
15991        assert!(
15992            md.contains("localId=9da8c2104471"),
15993            "caption localId should appear in markdown: {md}"
15994        );
15995        let rt = markdown_to_adf(&md).unwrap();
15996        let content = rt.content[0].content.as_ref().unwrap();
15997        let caption = &content[1];
15998        assert_eq!(caption.node_type, "caption");
15999        assert_eq!(
16000            caption.attrs.as_ref().unwrap()["localId"],
16001            "9da8c2104471",
16002            "caption localId should round-trip"
16003        );
16004    }
16005
16006    #[test]
16007    fn media_single_caption_without_localid() {
16008        let md = "![Screenshot](){type=file id=abc-123 collection=contentId-456 height=600 width=800}\n:::caption\nPlain caption\n:::\n";
16009        let doc = markdown_to_adf(md).unwrap();
16010        let caption = &doc.content[0].content.as_ref().unwrap()[1];
16011        assert_eq!(caption.node_type, "caption");
16012        assert!(
16013            caption.attrs.is_none(),
16014            "caption without localId should not gain attrs"
16015        );
16016        let md2 = adf_to_markdown(&doc).unwrap();
16017        assert!(
16018            !md2.contains("localId"),
16019            "no localId should appear in output: {md2}"
16020        );
16021    }
16022
16023    #[test]
16024    fn media_single_caption_localid_stripped_when_option_set() {
16025        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"}]}]}]}"#;
16026        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16027        let opts = RenderOptions {
16028            strip_local_ids: true,
16029            ..Default::default()
16030        };
16031        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
16032        assert!(!md.contains("localId"), "localId should be stripped: {md}");
16033    }
16034
16035    #[test]
16036    fn table_width_roundtrip() {
16037        // ADF table with width attribute
16038        let adf_doc = serde_json::json!({
16039            "type": "doc",
16040            "version": 1,
16041            "content": [{
16042                "type": "table",
16043                "attrs": {"layout": "default", "width": 760.0},
16044                "content": [{
16045                    "type": "tableRow",
16046                    "content": [{
16047                        "type": "tableHeader",
16048                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "H"}]}]
16049                    }]
16050                }]
16051            }]
16052        });
16053        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16054        let md = adf_to_markdown(&doc).unwrap();
16055        assert!(
16056            md.contains("width=760.0"),
16057            "expected width=760.0 in markdown (float preserved), got: {md}"
16058        );
16059        // Round-trip back to ADF
16060        let doc2 = markdown_to_adf(&md).unwrap();
16061        let table = &doc2.content[0];
16062        assert_eq!(table.node_type, "table");
16063        let table_attrs = table.attrs.as_ref().unwrap();
16064        assert_eq!(table_attrs["width"], 760.0);
16065        assert!(
16066            table_attrs["width"].is_f64(),
16067            "expected float width to be preserved as f64, got: {:?}",
16068            table_attrs["width"]
16069        );
16070    }
16071
16072    #[test]
16073    fn table_integer_width_roundtrip_preserves_integer() {
16074        // Issue #577: Integer width in ADF must survive roundtrip without being
16075        // coerced to a float.
16076        let adf_doc = serde_json::json!({
16077            "type": "doc",
16078            "version": 1,
16079            "content": [{
16080                "type": "table",
16081                "attrs": {
16082                    "isNumberColumnEnabled": false,
16083                    "layout": "center",
16084                    "localId": "abc-123",
16085                    "width": 1420
16086                },
16087                "content": [{
16088                    "type": "tableRow",
16089                    "content": [{
16090                        "type": "tableCell",
16091                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Cell"}]}]
16092                    }]
16093                }]
16094            }]
16095        });
16096        let doc: crate::atlassian::adf::AdfDocument =
16097            serde_json::from_value(adf_doc.clone()).unwrap();
16098        let md = adf_to_markdown(&doc).unwrap();
16099        assert!(
16100            md.contains("width=1420"),
16101            "expected width=1420 in markdown, got: {md}"
16102        );
16103        assert!(
16104            !md.contains("width=1420.0"),
16105            "integer width should not be rendered with decimal: {md}"
16106        );
16107
16108        let doc2 = markdown_to_adf(&md).unwrap();
16109        let table = &doc2.content[0];
16110        assert_eq!(table.node_type, "table");
16111        let table_attrs = table.attrs.as_ref().unwrap();
16112        assert_eq!(table_attrs["width"], 1420);
16113        assert!(
16114            table_attrs["width"].is_u64() || table_attrs["width"].is_i64(),
16115            "width should remain an integer, got: {:?}",
16116            table_attrs["width"]
16117        );
16118        assert!(
16119            !table_attrs["width"].is_f64(),
16120            "width should not be a float, got: {:?}",
16121            table_attrs["width"]
16122        );
16123
16124        // Full byte-fidelity: re-serialized ADF should match original JSON.
16125        let roundtripped = serde_json::to_value(&doc2).unwrap();
16126        let orig_width = &adf_doc["content"][0]["attrs"]["width"];
16127        let rt_width = &roundtripped["content"][0]["attrs"]["width"];
16128        assert_eq!(
16129            orig_width, rt_width,
16130            "width value must roundtrip byte-for-byte"
16131        );
16132    }
16133
16134    #[test]
16135    fn table_fractional_width_roundtrip() {
16136        // Fractional float widths should also roundtrip faithfully.
16137        let adf_doc = serde_json::json!({
16138            "type": "doc",
16139            "version": 1,
16140            "content": [{
16141                "type": "table",
16142                "attrs": {"layout": "default", "width": 760.5},
16143                "content": [{
16144                    "type": "tableRow",
16145                    "content": [{
16146                        "type": "tableHeader",
16147                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "H"}]}]
16148                    }]
16149                }]
16150            }]
16151        });
16152        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16153        let md = adf_to_markdown(&doc).unwrap();
16154        assert!(
16155            md.contains("width=760.5"),
16156            "expected width=760.5 in markdown, got: {md}"
16157        );
16158        let doc2 = markdown_to_adf(&md).unwrap();
16159        let table_attrs = doc2.content[0].attrs.as_ref().unwrap();
16160        assert_eq!(table_attrs["width"], 760.5);
16161        assert!(table_attrs["width"].is_f64());
16162    }
16163
16164    #[test]
16165    fn pipe_table_integer_width_roundtrip() {
16166        // Exercises the try_table() attrs-on-next-line parsing path.
16167        let md = "| A | B |\n|---|---|\n| 1 | 2 |\n{layout=default width=1420}\n";
16168        let doc = markdown_to_adf(md).unwrap();
16169        let table = &doc.content[0];
16170        assert_eq!(table.node_type, "table");
16171        let attrs = table.attrs.as_ref().unwrap();
16172        assert_eq!(attrs["width"], 1420);
16173        assert!(
16174            attrs["width"].is_u64() || attrs["width"].is_i64(),
16175            "pipe-table width must stay integer, got: {:?}",
16176            attrs["width"]
16177        );
16178    }
16179
16180    #[test]
16181    fn file_media_width_type_roundtrip() {
16182        // mediaSingle with widthType:pixel should survive round-trip
16183        let adf_doc = serde_json::json!({
16184            "type": "doc",
16185            "version": 1,
16186            "content": [{
16187                "type": "mediaSingle",
16188                "attrs": {"layout": "center", "width": 312, "widthType": "pixel"},
16189                "content": [{
16190                    "type": "media",
16191                    "attrs": {
16192                        "type": "file",
16193                        "id": "abc123",
16194                        "collection": "contentId-999",
16195                        "height": 56,
16196                        "width": 312
16197                    }
16198                }]
16199            }]
16200        });
16201        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16202        let md = adf_to_markdown(&doc).unwrap();
16203        assert!(
16204            md.contains("widthType=pixel"),
16205            "expected widthType=pixel in markdown, got: {md}"
16206        );
16207        let doc2 = markdown_to_adf(&md).unwrap();
16208        let ms = &doc2.content[0];
16209        let ms_attrs = ms.attrs.as_ref().unwrap();
16210        assert_eq!(ms_attrs["widthType"], "pixel");
16211        assert_eq!(ms_attrs["width"], 312);
16212    }
16213
16214    #[test]
16215    fn file_media_mode_roundtrip() {
16216        // mediaSingle with mode attr should survive round-trip (issue #431)
16217        let adf_doc = serde_json::json!({
16218            "type": "doc",
16219            "version": 1,
16220            "content": [{
16221                "type": "mediaSingle",
16222                "attrs": {"layout": "wide", "mode": "wide", "width": 1200},
16223                "content": [{
16224                    "type": "media",
16225                    "attrs": {
16226                        "type": "file",
16227                        "id": "abc123",
16228                        "collection": "test",
16229                        "width": 1200,
16230                        "height": 600
16231                    }
16232                }]
16233            }]
16234        });
16235        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16236        let md = adf_to_markdown(&doc).unwrap();
16237        assert!(
16238            md.contains("mode=wide"),
16239            "expected mode=wide in markdown, got: {md}"
16240        );
16241        let doc2 = markdown_to_adf(&md).unwrap();
16242        let ms = &doc2.content[0];
16243        let ms_attrs = ms.attrs.as_ref().unwrap();
16244        assert_eq!(ms_attrs["mode"], "wide");
16245        assert_eq!(ms_attrs["layout"], "wide");
16246        assert_eq!(ms_attrs["width"], 1200);
16247    }
16248
16249    #[test]
16250    fn external_media_mode_roundtrip() {
16251        // External mediaSingle with mode attr should survive round-trip (issue #431)
16252        let adf_doc = serde_json::json!({
16253            "type": "doc",
16254            "version": 1,
16255            "content": [{
16256                "type": "mediaSingle",
16257                "attrs": {"layout": "wide", "mode": "wide"},
16258                "content": [{
16259                    "type": "media",
16260                    "attrs": {
16261                        "type": "external",
16262                        "url": "https://example.com/image.png"
16263                    }
16264                }]
16265            }]
16266        });
16267        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16268        let md = adf_to_markdown(&doc).unwrap();
16269        assert!(
16270            md.contains("mode=wide"),
16271            "expected mode=wide in markdown, got: {md}"
16272        );
16273        let doc2 = markdown_to_adf(&md).unwrap();
16274        let ms = &doc2.content[0];
16275        let ms_attrs = ms.attrs.as_ref().unwrap();
16276        assert_eq!(ms_attrs["mode"], "wide");
16277        assert_eq!(ms_attrs["layout"], "wide");
16278    }
16279
16280    #[test]
16281    fn media_mode_only_roundtrip() {
16282        // mediaSingle with mode but default layout should still preserve mode (issue #431)
16283        let adf_doc = serde_json::json!({
16284            "type": "doc",
16285            "version": 1,
16286            "content": [{
16287                "type": "mediaSingle",
16288                "attrs": {"layout": "center", "mode": "default"},
16289                "content": [{
16290                    "type": "media",
16291                    "attrs": {
16292                        "type": "external",
16293                        "url": "https://example.com/image.png"
16294                    }
16295                }]
16296            }]
16297        });
16298        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16299        let md = adf_to_markdown(&doc).unwrap();
16300        assert!(
16301            md.contains("mode=default"),
16302            "expected mode=default in markdown, got: {md}"
16303        );
16304        let doc2 = markdown_to_adf(&md).unwrap();
16305        let ms = &doc2.content[0];
16306        let ms_attrs = ms.attrs.as_ref().unwrap();
16307        assert_eq!(ms_attrs["mode"], "default");
16308    }
16309
16310    #[test]
16311    fn file_media_hex_localid_roundtrip() {
16312        // Issue #432: short hex localId (non-UUID) must survive round-trip
16313        let adf_doc = serde_json::json!({
16314            "type": "doc",
16315            "version": 1,
16316            "content": [{
16317                "type": "mediaSingle",
16318                "attrs": {"layout": "wide", "width": 1200, "widthType": "pixel"},
16319                "content": [{
16320                    "type": "media",
16321                    "attrs": {
16322                        "type": "file",
16323                        "id": "eb7a9c3b-314e-4458-8200-4b22b67b122e",
16324                        "collection": "contentId-123",
16325                        "height": 484,
16326                        "width": 915,
16327                        "alt": "image.png",
16328                        "localId": "0e79f58ac382"
16329                    }
16330                }]
16331            }]
16332        });
16333        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16334        let md = adf_to_markdown(&doc).unwrap();
16335        assert!(
16336            md.contains("localId=0e79f58ac382"),
16337            "expected localId=0e79f58ac382 in markdown, got: {md}"
16338        );
16339        let doc2 = markdown_to_adf(&md).unwrap();
16340        let ms = &doc2.content[0];
16341        let media = &ms.content.as_ref().unwrap()[0];
16342        let attrs = media.attrs.as_ref().unwrap();
16343        assert_eq!(attrs["localId"], "0e79f58ac382");
16344    }
16345
16346    #[test]
16347    fn file_media_uuid_localid_roundtrip() {
16348        // UUID-format localId must also survive round-trip
16349        let adf_doc = serde_json::json!({
16350            "type": "doc",
16351            "version": 1,
16352            "content": [{
16353                "type": "mediaSingle",
16354                "attrs": {"layout": "center"},
16355                "content": [{
16356                    "type": "media",
16357                    "attrs": {
16358                        "type": "file",
16359                        "id": "abc-123",
16360                        "collection": "contentId-456",
16361                        "height": 100,
16362                        "width": 200,
16363                        "localId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
16364                    }
16365                }]
16366            }]
16367        });
16368        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16369        let md = adf_to_markdown(&doc).unwrap();
16370        assert!(
16371            md.contains("localId=a1b2c3d4-e5f6-7890-abcd-ef1234567890"),
16372            "expected UUID localId in markdown, got: {md}"
16373        );
16374        let doc2 = markdown_to_adf(&md).unwrap();
16375        let media = &doc2.content[0].content.as_ref().unwrap()[0];
16376        let attrs = media.attrs.as_ref().unwrap();
16377        assert_eq!(attrs["localId"], "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
16378    }
16379
16380    #[test]
16381    fn file_media_null_uuid_localid_stripped() {
16382        // Null UUID localId should be stripped (consistent with other node types)
16383        let adf_doc = serde_json::json!({
16384            "type": "doc",
16385            "version": 1,
16386            "content": [{
16387                "type": "mediaSingle",
16388                "attrs": {"layout": "center"},
16389                "content": [{
16390                    "type": "media",
16391                    "attrs": {
16392                        "type": "file",
16393                        "id": "abc-123",
16394                        "collection": "contentId-456",
16395                        "height": 100,
16396                        "width": 200,
16397                        "localId": "00000000-0000-0000-0000-000000000000"
16398                    }
16399                }]
16400            }]
16401        });
16402        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16403        let md = adf_to_markdown(&doc).unwrap();
16404        assert!(
16405            !md.contains("localId="),
16406            "null UUID localId should be stripped, got: {md}"
16407        );
16408    }
16409
16410    #[test]
16411    fn file_media_localid_stripped_when_option_set() {
16412        // localId should be stripped when strip_local_ids option is enabled
16413        let adf_doc = serde_json::json!({
16414            "type": "doc",
16415            "version": 1,
16416            "content": [{
16417                "type": "mediaSingle",
16418                "attrs": {"layout": "center"},
16419                "content": [{
16420                    "type": "media",
16421                    "attrs": {
16422                        "type": "file",
16423                        "id": "abc-123",
16424                        "collection": "contentId-456",
16425                        "height": 100,
16426                        "width": 200,
16427                        "localId": "0e79f58ac382"
16428                    }
16429                }]
16430            }]
16431        });
16432        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16433        let opts = RenderOptions {
16434            strip_local_ids: true,
16435            ..Default::default()
16436        };
16437        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
16438        assert!(
16439            !md.contains("localId="),
16440            "localId should be stripped with strip_local_ids, got: {md}"
16441        );
16442    }
16443
16444    #[test]
16445    fn external_media_localid_roundtrip() {
16446        // localId on external media nodes must also survive round-trip
16447        let adf_doc = serde_json::json!({
16448            "type": "doc",
16449            "version": 1,
16450            "content": [{
16451                "type": "mediaSingle",
16452                "attrs": {"layout": "center"},
16453                "content": [{
16454                    "type": "media",
16455                    "attrs": {
16456                        "type": "external",
16457                        "url": "https://example.com/image.png",
16458                        "alt": "test",
16459                        "localId": "deadbeef1234"
16460                    }
16461                }]
16462            }]
16463        });
16464        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16465        let md = adf_to_markdown(&doc).unwrap();
16466        assert!(
16467            md.contains("localId=deadbeef1234"),
16468            "expected localId in markdown for external media, got: {md}"
16469        );
16470        let doc2 = markdown_to_adf(&md).unwrap();
16471        let media = &doc2.content[0].content.as_ref().unwrap()[0];
16472        let attrs = media.attrs.as_ref().unwrap();
16473        assert_eq!(attrs["localId"], "deadbeef1234");
16474    }
16475
16476    #[test]
16477    fn bracket_in_text_not_parsed_as_link() {
16478        // "[Task] some text (Link)" — the [Task] must NOT be treated as a link anchor
16479        let md = ":check_mark: [Task] Unable to start trial ([Link](https://example.com/link))";
16480        let doc = markdown_to_adf(md).unwrap();
16481        let para = &doc.content[0];
16482        assert_eq!(para.node_type, "paragraph");
16483        let content = para.content.as_ref().unwrap();
16484        // Find the text node containing "[Task]"
16485        let text_nodes: Vec<_> = content.iter().filter(|n| n.node_type == "text").collect();
16486        let has_task_bracket = text_nodes
16487            .iter()
16488            .any(|n| n.text.as_deref().unwrap_or("").contains("[Task]"));
16489        assert!(
16490            has_task_bracket,
16491            "expected [Task] in plain text, nodes: {content:?}"
16492        );
16493        // Also verify the (Link) is a proper link
16494        let link_nodes: Vec<_> = content
16495            .iter()
16496            .filter(|n| {
16497                n.marks
16498                    .as_ref()
16499                    .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "link"))
16500            })
16501            .collect();
16502        assert!(!link_nodes.is_empty(), "expected a link node");
16503        assert_eq!(
16504            link_nodes[0].text.as_deref(),
16505            Some("Link"),
16506            "link text should be 'Link'"
16507        );
16508    }
16509
16510    #[test]
16511    fn empty_paragraph_roundtrip() {
16512        // An empty ADF paragraph node should survive a round-trip through markdown
16513        let mut adf_in = AdfDocument::new();
16514        adf_in.content = vec![
16515            AdfNode::paragraph(vec![AdfNode::text("before")]),
16516            AdfNode::paragraph(vec![]),
16517            AdfNode::paragraph(vec![AdfNode::text("after")]),
16518        ];
16519        let md = adf_to_markdown(&adf_in).unwrap();
16520        let adf_out = markdown_to_adf(&md).unwrap();
16521        assert_eq!(
16522            adf_out.content.len(),
16523            3,
16524            "should have 3 blocks, markdown:\n{md}"
16525        );
16526        assert_eq!(adf_out.content[0].node_type, "paragraph");
16527        assert_eq!(adf_out.content[1].node_type, "paragraph");
16528        assert!(
16529            adf_out.content[1].content.is_none(),
16530            "middle paragraph should be empty"
16531        );
16532        assert_eq!(adf_out.content[2].node_type, "paragraph");
16533    }
16534
16535    #[test]
16536    fn nbsp_paragraph_roundtrip() {
16537        // Issue #411: paragraph with only NBSP should survive round-trip
16538        let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}]}";
16539        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16540        let md = adf_to_markdown(&doc).unwrap();
16541        assert!(
16542            md.contains("::paragraph["),
16543            "NBSP paragraph should use directive form: {md}"
16544        );
16545        let rt = markdown_to_adf(&md).unwrap();
16546        assert_eq!(rt.content.len(), 1, "should have 1 block");
16547        assert_eq!(rt.content[0].node_type, "paragraph");
16548        let text = rt.content[0].content.as_ref().unwrap()[0]
16549            .text
16550            .as_deref()
16551            .unwrap_or("");
16552        assert_eq!(text, "\u{00a0}", "NBSP should survive round-trip");
16553    }
16554
16555    #[test]
16556    fn nbsp_in_nested_expand_roundtrip() {
16557        // Issue #411 real-world case: NBSP paragraph inside nestedExpand
16558        let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"nestedExpand\",\"attrs\":{\"title\":\"Section\"},\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}]}]}";
16559        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16560        let md = adf_to_markdown(&doc).unwrap();
16561        let rt = markdown_to_adf(&md).unwrap();
16562        let ne = &rt.content[0];
16563        assert_eq!(ne.node_type, "nestedExpand");
16564        let inner = ne.content.as_ref().unwrap();
16565        assert_eq!(inner.len(), 1, "should have 1 inner block");
16566        assert_eq!(inner[0].node_type, "paragraph");
16567        let content = inner[0].content.as_ref().unwrap();
16568        assert!(!content.is_empty(), "paragraph should not be empty");
16569        let text = content[0].text.as_deref().unwrap_or("");
16570        assert_eq!(text, "\u{00a0}", "NBSP should survive in nestedExpand");
16571    }
16572
16573    #[test]
16574    fn nbsp_followed_by_content() {
16575        // NBSP paragraph followed by regular content should not interfere
16576        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\"}]}]}";
16577        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16578        let md = adf_to_markdown(&doc).unwrap();
16579        let rt = markdown_to_adf(&md).unwrap();
16580        assert!(rt.content.len() >= 2, "should have at least 2 blocks");
16581        // The second block should be a paragraph with "after"
16582        let after_para = rt.content.iter().find(|n| {
16583            n.node_type == "paragraph"
16584                && n.content
16585                    .as_ref()
16586                    .and_then(|c| c.first())
16587                    .and_then(|n| n.text.as_deref())
16588                    .map_or(false, |t| t.contains("after"))
16589        });
16590        assert!(after_para.is_some(), "should have paragraph with 'after'");
16591    }
16592
16593    #[test]
16594    fn nbsp_paragraph_with_marks_survives() {
16595        // NBSP with bold marks renders as `** **` which contains non-whitespace
16596        // chars and thus doesn't need the directive form — it round-trips naturally
16597        let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\",\"marks\":[{\"type\":\"strong\"}]}]}]}";
16598        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16599        let md = adf_to_markdown(&doc).unwrap();
16600        assert!(md.contains("**"), "should have bold markers: {md}");
16601        let rt = markdown_to_adf(&md).unwrap();
16602        let content = rt.content[0].content.as_ref().unwrap();
16603        assert!(!content.is_empty(), "should preserve content");
16604    }
16605
16606    #[test]
16607    fn regular_paragraph_unchanged() {
16608        // Regression guard: normal paragraphs should NOT use directive form
16609        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}"#;
16610        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16611        let md = adf_to_markdown(&doc).unwrap();
16612        assert!(
16613            !md.contains("::paragraph"),
16614            "regular paragraphs should not use directive form: {md}"
16615        );
16616        assert!(md.contains("hello"));
16617    }
16618
16619    #[test]
16620    fn paragraph_directive_with_content_parsed() {
16621        // ::paragraph[content] should parse to a paragraph with inline nodes
16622        let md = "::paragraph[\u{00a0}]\n";
16623        let doc = markdown_to_adf(md).unwrap();
16624        assert_eq!(doc.content.len(), 1);
16625        assert_eq!(doc.content[0].node_type, "paragraph");
16626        let content = doc.content[0].content.as_ref().unwrap();
16627        assert!(!content.is_empty(), "should have inline content");
16628        assert_eq!(content[0].text.as_deref().unwrap(), "\u{00a0}");
16629    }
16630
16631    #[test]
16632    fn nbsp_paragraph_in_list_item_with_nested_list() {
16633        // Issue #448: NBSP paragraph content lost inside listItem with nested bulletList
16634        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"}]}]}]}]}]}]}"#;
16635        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16636        let md = adf_to_markdown(&doc).unwrap();
16637        let rt = markdown_to_adf(&md).unwrap();
16638        let list = &rt.content[0];
16639        assert_eq!(list.node_type, "bulletList");
16640        let item = &list.content.as_ref().unwrap()[0];
16641        let item_content = item.content.as_ref().unwrap();
16642        assert_eq!(
16643            item_content.len(),
16644            2,
16645            "listItem should have paragraph + nested list, got: {item_content:?}"
16646        );
16647        let para = &item_content[0];
16648        assert_eq!(para.node_type, "paragraph");
16649        let para_content = para
16650            .content
16651            .as_ref()
16652            .expect("paragraph should have content");
16653        assert!(
16654            !para_content.is_empty(),
16655            "NBSP paragraph content should not be empty"
16656        );
16657        assert_eq!(
16658            para_content[0].text.as_deref().unwrap(),
16659            "\u{00a0}",
16660            "NBSP should survive round-trip inside listItem"
16661        );
16662    }
16663
16664    #[test]
16665    fn nbsp_paragraph_in_list_item_with_local_ids() {
16666        // Issue #448: NBSP paragraph with localIds inside listItem with nested list
16667        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"}]}]}]}]}]}]}"#;
16668        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16669        let md = adf_to_markdown(&doc).unwrap();
16670        let rt = markdown_to_adf(&md).unwrap();
16671        let list = &rt.content[0];
16672        let item = &list.content.as_ref().unwrap()[0];
16673        // Check listItem localId
16674        assert_eq!(
16675            item.attrs.as_ref().unwrap()["localId"],
16676            "li-001",
16677            "listItem localId should survive"
16678        );
16679        let item_content = item.content.as_ref().unwrap();
16680        assert_eq!(item_content.len(), 2);
16681        // Check paragraph localId and NBSP content
16682        let para = &item_content[0];
16683        assert_eq!(
16684            para.attrs.as_ref().unwrap()["localId"],
16685            "p-001",
16686            "paragraph localId should survive"
16687        );
16688        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
16689        assert_eq!(text, "\u{00a0}", "NBSP should survive with localIds");
16690    }
16691
16692    #[test]
16693    fn nbsp_paragraph_in_list_item_without_nested_list() {
16694        // NBSP paragraph in a simple listItem (no nested list)
16695        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"}]}]}]}]}"#;
16696        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16697        let md = adf_to_markdown(&doc).unwrap();
16698        let rt = markdown_to_adf(&md).unwrap();
16699        let list = &rt.content[0];
16700        let item = &list.content.as_ref().unwrap()[0];
16701        let para = &item.content.as_ref().unwrap()[0];
16702        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
16703        assert_eq!(text, "\u{00a0}", "NBSP should survive in simple list item");
16704    }
16705
16706    #[test]
16707    fn nbsp_paragraph_in_ordered_list_item_with_nested_list() {
16708        // NBSP paragraph in ordered listItem with nested bulletList
16709        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"}]}]}]}]}]}]}"#;
16710        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16711        let md = adf_to_markdown(&doc).unwrap();
16712        let rt = markdown_to_adf(&md).unwrap();
16713        let list = &rt.content[0];
16714        let item = &list.content.as_ref().unwrap()[0];
16715        let item_content = item.content.as_ref().unwrap();
16716        assert_eq!(item_content.len(), 2);
16717        let para = &item_content[0];
16718        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
16719        assert_eq!(text, "\u{00a0}", "NBSP should survive in ordered list item");
16720    }
16721
16722    #[test]
16723    fn list_item_leading_space_preserved() {
16724        // Leading space in list item text must not be stripped
16725        let md = "- hello world\n- - text";
16726        let doc = markdown_to_adf(md).unwrap();
16727        let list = &doc.content[0];
16728        assert_eq!(list.node_type, "bulletList");
16729        let items = list.content.as_ref().unwrap();
16730        // First item: "hello world" (no leading space, unchanged)
16731        let first_para = &items[0].content.as_ref().unwrap()[0];
16732        let first_text = &first_para.content.as_ref().unwrap()[0];
16733        assert_eq!(first_text.text.as_deref(), Some("hello world"));
16734    }
16735
16736    #[test]
16737    fn list_item_leading_space_not_stripped() {
16738        // When the markdown list item content has a leading space (e.g. " :emoji:"),
16739        // that space must reach parse_inline as-is.
16740        let md = "-  leading space text";
16741        let doc = markdown_to_adf(md).unwrap();
16742        let list = &doc.content[0];
16743        let items = list.content.as_ref().unwrap();
16744        let para = &items[0].content.as_ref().unwrap()[0];
16745        let text_node = &para.content.as_ref().unwrap()[0];
16746        // After "- " (2 chars), trim_end keeps the leading space: " leading space text"
16747        assert_eq!(
16748            text_node.text.as_deref(),
16749            Some(" leading space text"),
16750            "leading space should be preserved"
16751        );
16752    }
16753
16754    // ── Nested container directive tests ───────────────────────────
16755
16756    // ── hardBreak in table cell tests ────────────────────────────
16757
16758    #[test]
16759    fn hardbreak_in_cell_uses_directive_table() {
16760        // A table cell with a hardBreak should NOT use pipe syntax
16761        // because the newline would break the row
16762        let adf = AdfDocument {
16763            version: 1,
16764            doc_type: "doc".to_string(),
16765            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
16766                AdfNode::table_cell(vec![AdfNode::paragraph(vec![
16767                    AdfNode::text("before"),
16768                    AdfNode::hard_break(),
16769                    AdfNode::text("after"),
16770                ])]),
16771            ])])],
16772        };
16773        let md = adf_to_markdown(&adf).unwrap();
16774        // Should render as directive table, not pipe table
16775        assert!(
16776            md.contains(":::td") || md.contains("::::table"),
16777            "Table with hardBreak should use directive form, got:\n{md}"
16778        );
16779        assert!(
16780            !md.contains("| before"),
16781            "Should NOT use pipe syntax with hardBreak"
16782        );
16783    }
16784
16785    #[test]
16786    fn hardbreak_in_cell_roundtrips() {
16787        // Verify the directive table form preserves the hardBreak on round-trip
16788        let adf = AdfDocument {
16789            version: 1,
16790            doc_type: "doc".to_string(),
16791            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
16792                AdfNode::table_cell(vec![AdfNode::paragraph(vec![
16793                    AdfNode::text("line one"),
16794                    AdfNode::hard_break(),
16795                    AdfNode::text("line two"),
16796                ])]),
16797            ])])],
16798        };
16799        let md = adf_to_markdown(&adf).unwrap();
16800        let roundtripped = markdown_to_adf(&md).unwrap();
16801
16802        // Should still have one table with one row with one cell
16803        assert_eq!(roundtripped.content.len(), 1);
16804        assert_eq!(roundtripped.content[0].node_type, "table");
16805        let rows = roundtripped.content[0].content.as_ref().unwrap();
16806        assert_eq!(
16807            rows.len(),
16808            1,
16809            "Should have exactly 1 row, got {}",
16810            rows.len()
16811        );
16812    }
16813
16814    #[test]
16815    fn hardbreak_in_paragraph_roundtrips() {
16816        // Issue #373: hardBreak absorbed into preceding text node
16817        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
16818          {"type":"text","text":"line one"},
16819          {"type":"hardBreak"},
16820          {"type":"text","text":"line two"}
16821        ]}]}"#;
16822        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16823        let md = adf_to_markdown(&doc).unwrap();
16824        let round_tripped = markdown_to_adf(&md).unwrap();
16825        let inlines = round_tripped.content[0].content.as_ref().unwrap();
16826        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
16827        assert_eq!(
16828            types,
16829            vec!["text", "hardBreak", "text"],
16830            "hardBreak should be preserved, got: {types:?}"
16831        );
16832        assert_eq!(inlines[0].text.as_deref(), Some("line one"));
16833        assert_eq!(inlines[2].text.as_deref(), Some("line two"));
16834    }
16835
16836    #[test]
16837    fn consecutive_hardbreaks_in_paragraph_roundtrip() {
16838        // Issue #410: consecutive hardBreak nodes collapsed on round-trip
16839        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
16840          {"type":"text","text":"before"},
16841          {"type":"hardBreak"},
16842          {"type":"hardBreak"},
16843          {"type":"text","text":"after"}
16844        ]}]}"#;
16845        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16846        let md = adf_to_markdown(&doc).unwrap();
16847        let round_tripped = markdown_to_adf(&md).unwrap();
16848        assert_eq!(
16849            round_tripped.content.len(),
16850            1,
16851            "Should remain a single paragraph, got {} blocks",
16852            round_tripped.content.len()
16853        );
16854        let inlines = round_tripped.content[0].content.as_ref().unwrap();
16855        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
16856        assert_eq!(
16857            types,
16858            vec!["text", "hardBreak", "hardBreak", "text"],
16859            "Both hardBreaks should be preserved, got: {types:?}"
16860        );
16861        assert_eq!(inlines[0].text.as_deref(), Some("before"));
16862        assert_eq!(inlines[3].text.as_deref(), Some("after"));
16863    }
16864
16865    #[test]
16866    fn hardbreak_only_paragraph_roundtrips() {
16867        // Issue #410: paragraph whose only content is a hardBreak is dropped
16868        let adf_json = r#"{"version":1,"type":"doc","content":[
16869          {"type":"paragraph","content":[{"type":"hardBreak"}]}
16870        ]}"#;
16871        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16872        let md = adf_to_markdown(&doc).unwrap();
16873        let round_tripped = markdown_to_adf(&md).unwrap();
16874        assert_eq!(
16875            round_tripped.content.len(),
16876            1,
16877            "Paragraph should not be dropped, got {} blocks",
16878            round_tripped.content.len()
16879        );
16880        let inlines = round_tripped.content[0].content.as_ref().unwrap();
16881        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
16882        assert_eq!(
16883            types,
16884            vec!["hardBreak"],
16885            "hardBreak-only paragraph should preserve its content, got: {types:?}"
16886        );
16887    }
16888
16889    #[test]
16890    fn issue_410_full_reproducer_roundtrips() {
16891        // Full reproducer from issue #410: consecutive hardBreaks + hardBreak-only paragraph
16892        let adf_json = r#"{"version":1,"type":"doc","content":[
16893          {"type":"paragraph","content":[
16894            {"type":"text","text":"before"},
16895            {"type":"hardBreak"},
16896            {"type":"hardBreak"},
16897            {"type":"text","text":"after"}
16898          ]},
16899          {"type":"paragraph","content":[
16900            {"type":"hardBreak"}
16901          ]}
16902        ]}"#;
16903        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16904        let md = adf_to_markdown(&doc).unwrap();
16905        let round_tripped = markdown_to_adf(&md).unwrap();
16906        assert_eq!(
16907            round_tripped.content.len(),
16908            2,
16909            "Should have exactly 2 paragraphs, got {}",
16910            round_tripped.content.len()
16911        );
16912        // First paragraph: text, hardBreak, hardBreak, text
16913        let p1 = round_tripped.content[0].content.as_ref().unwrap();
16914        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
16915        assert_eq!(types1, vec!["text", "hardBreak", "hardBreak", "text"]);
16916        // Second paragraph: hardBreak only
16917        let p2 = round_tripped.content[1].content.as_ref().unwrap();
16918        let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
16919        assert_eq!(types2, vec!["hardBreak"]);
16920    }
16921
16922    #[test]
16923    fn trailing_space_hardbreak_still_parsed() {
16924        // Backward compatibility: trailing-space hardBreak (old JFM format) still parses
16925        let md = "line one  \nline two\n";
16926        let doc = markdown_to_adf(md).unwrap();
16927        let inlines = doc.content[0].content.as_ref().unwrap();
16928        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
16929        assert_eq!(
16930            types,
16931            vec!["text", "hardBreak", "text"],
16932            "Trailing-space hardBreak should still parse, got: {types:?}"
16933        );
16934    }
16935
16936    #[test]
16937    fn trailing_hardbreak_at_end_of_paragraph_roundtrips() {
16938        // A paragraph ending with a hardBreak (no text after it)
16939        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
16940          {"type":"text","text":"text"},
16941          {"type":"hardBreak"}
16942        ]}]}"#;
16943        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16944        let md = adf_to_markdown(&doc).unwrap();
16945        let round_tripped = markdown_to_adf(&md).unwrap();
16946        let inlines = round_tripped.content[0].content.as_ref().unwrap();
16947        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
16948        assert_eq!(
16949            types,
16950            vec!["text", "hardBreak"],
16951            "Trailing hardBreak should be preserved, got: {types:?}"
16952        );
16953    }
16954
16955    #[test]
16956    #[test]
16957    fn table_with_header_row_uses_pipe_syntax() {
16958        // A table with tableHeader in the first row should use pipe syntax
16959        let adf = AdfDocument {
16960            version: 1,
16961            doc_type: "doc".to_string(),
16962            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
16963                AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("header cell")])]),
16964            ])])],
16965        };
16966        let md = adf_to_markdown(&adf).unwrap();
16967        assert!(
16968            md.contains("| header cell |"),
16969            "Table with header row should use pipe syntax, got:\n{md}"
16970        );
16971    }
16972
16973    #[test]
16974    fn table_without_header_row_uses_directive_syntax() {
16975        // Issue #392: tableCell-only first row must use directive syntax
16976        // to avoid converting tableCell → tableHeader on round-trip
16977        let adf = AdfDocument {
16978            version: 1,
16979            doc_type: "doc".to_string(),
16980            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
16981                AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("simple cell")])]),
16982            ])])],
16983        };
16984        let md = adf_to_markdown(&adf).unwrap();
16985        assert!(
16986            md.contains("::::table"),
16987            "Table without header row should use directive syntax, got:\n{md}"
16988        );
16989    }
16990
16991    #[test]
16992    fn tablecell_first_row_preserved_on_roundtrip() {
16993        // Issue #392: tableCell in first row round-trips as tableHeader
16994        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{},"content":[
16995          {"type":"tableRow","content":[
16996            {"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"row1 cell"}]}]}
16997          ]},
16998          {"type":"tableRow","content":[
16999            {"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"row2 cell"}]}]}
17000          ]}
17001        ]}]}"#;
17002        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17003        let md = adf_to_markdown(&doc).unwrap();
17004        let round_tripped = markdown_to_adf(&md).unwrap();
17005        let rows = round_tripped.content[0].content.as_ref().unwrap();
17006        let row0_cell = &rows[0].content.as_ref().unwrap()[0];
17007        assert_eq!(
17008            row0_cell.node_type, "tableCell",
17009            "first row cell should remain tableCell, got: {}",
17010            row0_cell.node_type
17011        );
17012        let row1_cell = &rows[1].content.as_ref().unwrap()[0];
17013        assert_eq!(row1_cell.node_type, "tableCell");
17014    }
17015
17016    #[test]
17017    fn mixed_header_and_cell_first_row_uses_pipe() {
17018        // A first row with at least one tableHeader qualifies for pipe syntax
17019        let adf = AdfDocument {
17020            version: 1,
17021            doc_type: "doc".to_string(),
17022            content: vec![AdfNode::table(vec![
17023                AdfNode::table_row(vec![
17024                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
17025                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
17026                ]),
17027                AdfNode::table_row(vec![
17028                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C1")])]),
17029                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
17030                ]),
17031            ])],
17032        };
17033        let md = adf_to_markdown(&adf).unwrap();
17034        assert!(
17035            md.contains("| H1 |"),
17036            "Table with header first row should use pipe syntax, got:\n{md}"
17037        );
17038        assert!(!md.contains("::::table"), "should not use directive syntax");
17039    }
17040
17041    // ── Issue #579: pipes in pipe-table cells ─────────────────────
17042
17043    #[test]
17044    fn render_pipe_table_escapes_pipe_in_code_span_cell() {
17045        // A code-marked text node with a literal `|` in a pipe-table cell
17046        // must emit `\|` so the column separator is unambiguous.
17047        let adf = AdfDocument {
17048            version: 1,
17049            doc_type: "doc".to_string(),
17050            content: vec![AdfNode::table(vec![
17051                AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
17052                    AdfNode::text("Header"),
17053                ])])]),
17054                AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
17055                    AdfNode::text_with_marks("a|b", vec![AdfMark::code()]),
17056                ])])]),
17057            ])],
17058        };
17059        let md = adf_to_markdown(&adf).unwrap();
17060        assert!(
17061            md.contains(r"`a\|b`"),
17062            "Pipe inside code span must be escaped, got:\n{md}"
17063        );
17064    }
17065
17066    #[test]
17067    fn render_pipe_table_escapes_pipe_in_plain_text_cell() {
17068        let adf = AdfDocument {
17069            version: 1,
17070            doc_type: "doc".to_string(),
17071            content: vec![AdfNode::table(vec![
17072                AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
17073                    AdfNode::text("Header"),
17074                ])])]),
17075                AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
17076                    AdfNode::text("x|y"),
17077                ])])]),
17078            ])],
17079        };
17080        let md = adf_to_markdown(&adf).unwrap();
17081        assert!(
17082            md.contains(r"x\|y"),
17083            "Pipe inside plain-text cell must be escaped, got:\n{md}"
17084        );
17085    }
17086
17087    #[test]
17088    fn code_span_with_pipe_in_table_cell_roundtrips() {
17089        // Issue #579 reproducer: code span containing `|` in a pipe-table cell.
17090        let adf_json = r#"{
17091            "version": 1,
17092            "type": "doc",
17093            "content": [{
17094                "type": "table",
17095                "attrs": {"isNumberColumnEnabled": false, "layout": "default", "localId": "abc-789"},
17096                "content": [
17097                    {"type": "tableRow", "content": [
17098                        {"type": "tableHeader", "attrs": {}, "content": [
17099                            {"type": "paragraph", "content": [{"type": "text", "text": "Before"}]}
17100                        ]},
17101                        {"type": "tableHeader", "attrs": {}, "content": [
17102                            {"type": "paragraph", "content": [{"type": "text", "text": "After"}]}
17103                        ]}
17104                    ]},
17105                    {"type": "tableRow", "content": [
17106                        {"type": "tableCell", "attrs": {}, "content": [
17107                            {"type": "paragraph", "content": [
17108                                {"type": "text", "text": "parse(json).extract[T]", "marks": [{"type": "code"}]}
17109                            ]}
17110                        ]},
17111                        {"type": "tableCell", "attrs": {}, "content": [
17112                            {"type": "paragraph", "content": [
17113                                {"type": "text", "text": "parser.decode[T|json]", "marks": [{"type": "code"}]}
17114                            ]}
17115                        ]}
17116                    ]}
17117                ]
17118            }]
17119        }"#;
17120        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17121        let md = adf_to_markdown(&doc).unwrap();
17122        let round_tripped = markdown_to_adf(&md).unwrap();
17123
17124        let rows = round_tripped.content[0].content.as_ref().unwrap();
17125        assert_eq!(
17126            rows.len(),
17127            2,
17128            "Table should have 2 rows, got: {}",
17129            rows.len()
17130        );
17131
17132        let body_row = rows[1].content.as_ref().unwrap();
17133        assert_eq!(
17134            body_row.len(),
17135            2,
17136            "Body row should have 2 cells (not split by the pipe), got: {}",
17137            body_row.len()
17138        );
17139
17140        let second_cell = &body_row[1];
17141        let para = second_cell.content.as_ref().unwrap().first().unwrap();
17142        let inlines = para.content.as_ref().unwrap();
17143        assert_eq!(inlines.len(), 1, "Cell should have a single text node");
17144        assert_eq!(
17145            inlines[0].text.as_deref(),
17146            Some("parser.decode[T|json]"),
17147            "Code-span text must be preserved with literal pipe"
17148        );
17149        let marks = inlines[0]
17150            .marks
17151            .as_ref()
17152            .expect("code mark must be preserved");
17153        assert!(
17154            marks.iter().any(|m| m.mark_type == "code"),
17155            "text node should carry the code mark"
17156        );
17157    }
17158
17159    #[test]
17160    fn plain_text_pipe_in_table_cell_roundtrips() {
17161        // Plain text with `|` in a pipe-table cell should also survive.
17162        let adf = AdfDocument {
17163            version: 1,
17164            doc_type: "doc".to_string(),
17165            content: vec![AdfNode::table(vec![
17166                AdfNode::table_row(vec![
17167                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
17168                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
17169                ]),
17170                AdfNode::table_row(vec![
17171                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("a|b")])]),
17172                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("c")])]),
17173                ]),
17174            ])],
17175        };
17176        let md = adf_to_markdown(&adf).unwrap();
17177        let round_tripped = markdown_to_adf(&md).unwrap();
17178        let rows = round_tripped.content[0].content.as_ref().unwrap();
17179        let body_row = rows[1].content.as_ref().unwrap();
17180        assert_eq!(
17181            body_row.len(),
17182            2,
17183            "Body row should keep 2 cells, got: {}",
17184            body_row.len()
17185        );
17186        let first_cell_text = body_row[0].content.as_ref().unwrap()[0]
17187            .content
17188            .as_ref()
17189            .unwrap()[0]
17190            .text
17191            .as_deref();
17192        assert_eq!(first_cell_text, Some("a|b"));
17193    }
17194
17195    #[test]
17196    fn cell_contains_hard_break_true() {
17197        let para = AdfNode::paragraph(vec![
17198            AdfNode::text("a"),
17199            AdfNode::hard_break(),
17200            AdfNode::text("b"),
17201        ]);
17202        assert!(cell_contains_hard_break(&para));
17203    }
17204
17205    #[test]
17206    fn cell_contains_hard_break_false() {
17207        let para = AdfNode::paragraph(vec![AdfNode::text("no break here")]);
17208        assert!(!cell_contains_hard_break(&para));
17209    }
17210
17211    #[test]
17212    fn cell_contains_hard_break_empty() {
17213        let para = AdfNode::paragraph(vec![]);
17214        assert!(!cell_contains_hard_break(&para));
17215    }
17216
17217    // ── Multi-paragraph container tests ──────────────────────────
17218
17219    #[test]
17220    fn multi_paragraph_panel_roundtrips() {
17221        let adf = AdfDocument {
17222            version: 1,
17223            doc_type: "doc".to_string(),
17224            content: vec![AdfNode {
17225                node_type: "panel".to_string(),
17226                attrs: Some(serde_json::json!({"panelType": "info"})),
17227                content: Some(vec![
17228                    AdfNode::paragraph(vec![AdfNode::text("First paragraph.")]),
17229                    AdfNode::paragraph(vec![AdfNode::text("Second paragraph.")]),
17230                ]),
17231                text: None,
17232                marks: None,
17233                local_id: None,
17234                parameters: None,
17235            }],
17236        };
17237
17238        let md = adf_to_markdown(&adf).unwrap();
17239        // Should have blank line between paragraphs inside the panel
17240        assert!(
17241            md.contains("First paragraph.\n\nSecond paragraph."),
17242            "Panel should have blank line between paragraphs, got:\n{md}"
17243        );
17244
17245        // Round-trip should preserve two separate paragraphs
17246        let roundtripped = markdown_to_adf(&md).unwrap();
17247        assert_eq!(roundtripped.content.len(), 1);
17248        assert_eq!(roundtripped.content[0].node_type, "panel");
17249        let panel_content = roundtripped.content[0].content.as_ref().unwrap();
17250        assert_eq!(
17251            panel_content.len(),
17252            2,
17253            "Panel should have 2 paragraphs after round-trip, got {}",
17254            panel_content.len()
17255        );
17256    }
17257
17258    #[test]
17259    fn multi_paragraph_expand_roundtrips() {
17260        let adf = AdfDocument {
17261            version: 1,
17262            doc_type: "doc".to_string(),
17263            content: vec![AdfNode {
17264                node_type: "expand".to_string(),
17265                attrs: Some(serde_json::json!({"title": "Details"})),
17266                content: Some(vec![
17267                    AdfNode::paragraph(vec![AdfNode::text("Para one.")]),
17268                    AdfNode::paragraph(vec![AdfNode::text("Para two.")]),
17269                ]),
17270                text: None,
17271                marks: None,
17272                local_id: None,
17273                parameters: None,
17274            }],
17275        };
17276
17277        let md = adf_to_markdown(&adf).unwrap();
17278        let roundtripped = markdown_to_adf(&md).unwrap();
17279        let expand_content = roundtripped.content[0].content.as_ref().unwrap();
17280        assert_eq!(
17281            expand_content.len(),
17282            2,
17283            "Expand should have 2 paragraphs after round-trip, got {}",
17284            expand_content.len()
17285        );
17286    }
17287
17288    #[test]
17289    fn consecutive_nested_expands_in_table_cell_roundtrip() {
17290        let cell_content = vec![
17291            AdfNode {
17292                node_type: "nestedExpand".to_string(),
17293                attrs: Some(serde_json::json!({"title": "First"})),
17294                content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("item 1")])]),
17295                text: None,
17296                marks: None,
17297                local_id: None,
17298                parameters: None,
17299            },
17300            AdfNode {
17301                node_type: "nestedExpand".to_string(),
17302                attrs: Some(serde_json::json!({"title": "Second"})),
17303                content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("item 2")])]),
17304                text: None,
17305                marks: None,
17306                local_id: None,
17307                parameters: None,
17308            },
17309        ];
17310        let adf = AdfDocument {
17311            version: 1,
17312            doc_type: "doc".to_string(),
17313            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17314                AdfNode::table_cell(cell_content),
17315            ])])],
17316        };
17317
17318        let md = adf_to_markdown(&adf).unwrap();
17319        assert!(
17320            md.contains(":::\n\n:::nested-expand"),
17321            "Should have blank line between consecutive nested-expands in cell, got:\n{md}"
17322        );
17323
17324        let rt = markdown_to_adf(&md).unwrap();
17325        let cell = &rt.content[0].content.as_ref().unwrap()[0]
17326            .content
17327            .as_ref()
17328            .unwrap()[0];
17329        let cell_nodes = cell.content.as_ref().unwrap();
17330        let expand_count = cell_nodes
17331            .iter()
17332            .filter(|n| n.node_type == "nestedExpand")
17333            .count();
17334        assert_eq!(
17335            expand_count, 2,
17336            "Both nested-expands should survive round-trip, got {expand_count}"
17337        );
17338    }
17339
17340    #[test]
17341    fn multi_paragraph_in_table_cell_roundtrip() {
17342        // Two paragraphs inside a directive table cell should survive round-trip
17343        let adf = AdfDocument {
17344            version: 1,
17345            doc_type: "doc".to_string(),
17346            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17347                AdfNode::table_cell(vec![
17348                    AdfNode::paragraph(vec![AdfNode::text("Para one.")]),
17349                    AdfNode::paragraph(vec![AdfNode::text("Para two.")]),
17350                ]),
17351            ])])],
17352        };
17353
17354        let md = adf_to_markdown(&adf).unwrap();
17355        assert!(
17356            md.contains("Para one.\n\nPara two."),
17357            "Should have blank line between paragraphs in cell, got:\n{md}"
17358        );
17359
17360        let rt = markdown_to_adf(&md).unwrap();
17361        let cell = &rt.content[0].content.as_ref().unwrap()[0]
17362            .content
17363            .as_ref()
17364            .unwrap()[0];
17365        let para_count = cell
17366            .content
17367            .as_ref()
17368            .unwrap()
17369            .iter()
17370            .filter(|n| n.node_type == "paragraph")
17371            .count();
17372        assert_eq!(para_count, 2, "Both paragraphs should survive round-trip");
17373    }
17374
17375    #[test]
17376    fn panel_inside_table_cell_roundtrip() {
17377        // A panel inside a directive table cell
17378        let adf = AdfDocument {
17379            version: 1,
17380            doc_type: "doc".to_string(),
17381            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17382                AdfNode::table_cell(vec![
17383                    AdfNode::paragraph(vec![AdfNode::text("Before panel.")]),
17384                    AdfNode {
17385                        node_type: "panel".to_string(),
17386                        attrs: Some(serde_json::json!({"panelType": "info"})),
17387                        content: Some(vec![AdfNode::paragraph(vec![AdfNode::text(
17388                            "Panel content",
17389                        )])]),
17390                        text: None,
17391                        marks: None,
17392                        local_id: None,
17393                        parameters: None,
17394                    },
17395                ]),
17396            ])])],
17397        };
17398
17399        let md = adf_to_markdown(&adf).unwrap();
17400        assert!(
17401            md.contains(":::panel"),
17402            "Should contain panel directive, got:\n{md}"
17403        );
17404
17405        let rt = markdown_to_adf(&md).unwrap();
17406        let cell = &rt.content[0].content.as_ref().unwrap()[0]
17407            .content
17408            .as_ref()
17409            .unwrap()[0];
17410        let has_panel = cell
17411            .content
17412            .as_ref()
17413            .unwrap()
17414            .iter()
17415            .any(|n| n.node_type == "panel");
17416        assert!(has_panel, "Panel should survive round-trip in table cell");
17417    }
17418
17419    #[test]
17420    fn three_consecutive_expands_in_table_cell() {
17421        let make_expand = |title: &str| AdfNode {
17422            node_type: "nestedExpand".to_string(),
17423            attrs: Some(serde_json::json!({"title": title})),
17424            content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("content")])]),
17425            text: None,
17426            marks: None,
17427            local_id: None,
17428            parameters: None,
17429        };
17430        let adf = AdfDocument {
17431            version: 1,
17432            doc_type: "doc".to_string(),
17433            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17434                AdfNode::table_cell(vec![
17435                    make_expand("First"),
17436                    make_expand("Second"),
17437                    make_expand("Third"),
17438                ]),
17439            ])])],
17440        };
17441
17442        let md = adf_to_markdown(&adf).unwrap();
17443        let rt = markdown_to_adf(&md).unwrap();
17444        let cell = &rt.content[0].content.as_ref().unwrap()[0]
17445            .content
17446            .as_ref()
17447            .unwrap()[0];
17448        let expand_count = cell
17449            .content
17450            .as_ref()
17451            .unwrap()
17452            .iter()
17453            .filter(|n| n.node_type == "nestedExpand")
17454            .count();
17455        assert_eq!(expand_count, 3, "All 3 expands should survive round-trip");
17456    }
17457
17458    // ── Nested container directive tests ───────────────────────────
17459
17460    #[test]
17461    fn nested_expand_inside_panel() {
17462        let md = ":::panel{type=info}\n:::expand{title=\"Details\"}\nHidden content\n:::\nMore panel content\n:::";
17463        let adf = markdown_to_adf(md).unwrap();
17464
17465        // Should produce a panel node
17466        assert_eq!(adf.content.len(), 1);
17467        assert_eq!(adf.content[0].node_type, "panel");
17468
17469        // Panel should contain the expand AND "More panel content"
17470        let panel_content = adf.content[0].content.as_ref().unwrap();
17471        assert!(
17472            panel_content.len() >= 2,
17473            "Panel should contain expand + paragraph, got {} nodes",
17474            panel_content.len()
17475        );
17476    }
17477
17478    #[test]
17479    fn nested_expand_inside_table_cell() {
17480        let md = "::::table\n:::tr\n:::td\n:::expand{title=\"Details\"}\nExpand content\n:::\n:::\n:::\n::::";
17481        let adf = markdown_to_adf(md).unwrap();
17482
17483        // Should produce a table
17484        assert_eq!(adf.content.len(), 1);
17485        assert_eq!(adf.content[0].node_type, "table");
17486
17487        // Table -> row -> cell -> should contain an expand node
17488        let rows = adf.content[0].content.as_ref().unwrap();
17489        assert_eq!(rows.len(), 1);
17490        let cells = rows[0].content.as_ref().unwrap();
17491        assert_eq!(cells.len(), 1);
17492        let cell_content = cells[0].content.as_ref().unwrap();
17493        assert!(
17494            cell_content.iter().any(|n| n.node_type == "expand"),
17495            "Cell should contain an expand node, got: {:?}",
17496            cell_content
17497                .iter()
17498                .map(|n| &n.node_type)
17499                .collect::<Vec<_>>()
17500        );
17501    }
17502
17503    #[test]
17504    fn nested_expand_inside_layout_column() {
17505        let md = ":::layout\n:::column{width=100}\n:::expand{title=\"Col Expand\"}\nExpanded\n:::\n:::\n:::";
17506        let adf = markdown_to_adf(md).unwrap();
17507
17508        assert_eq!(adf.content.len(), 1);
17509        assert_eq!(adf.content[0].node_type, "layoutSection");
17510
17511        let columns = adf.content[0].content.as_ref().unwrap();
17512        assert_eq!(columns.len(), 1);
17513        let col_content = columns[0].content.as_ref().unwrap();
17514        assert!(
17515            col_content.iter().any(|n| n.node_type == "expand"),
17516            "Column should contain an expand node, got: {:?}",
17517            col_content.iter().map(|n| &n.node_type).collect::<Vec<_>>()
17518        );
17519    }
17520
17521    #[test]
17522    fn expand_localid_in_directive_attrs() {
17523        // Issue #412: localId should be in directive attrs, not trailing text
17524        let adf_json = r#"{"version":1,"type":"doc","content":[
17525          {"type":"expand","attrs":{"localId":"exp-001","title":"Details"},"content":[
17526            {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17527          ]}
17528        ]}"#;
17529        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17530        let md = adf_to_markdown(&doc).unwrap();
17531        assert!(
17532            md.contains("localId=exp-001"),
17533            "should contain localId: {md}"
17534        );
17535        assert!(
17536            md.contains(":::expand{"),
17537            "should have expand directive with attrs: {md}"
17538        );
17539        assert!(
17540            !md.contains(":::\n{localId="),
17541            "localId should NOT be trailing: {md}"
17542        );
17543    }
17544
17545    #[test]
17546    fn expand_localid_roundtrip() {
17547        let adf_json = r#"{"version":1,"type":"doc","content":[
17548          {"type":"expand","attrs":{"localId":"exp-001","title":"Details"},"content":[
17549            {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17550          ]}
17551        ]}"#;
17552        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17553        let md = adf_to_markdown(&doc).unwrap();
17554        let rt = markdown_to_adf(&md).unwrap();
17555        let expand = &rt.content[0];
17556        assert_eq!(expand.node_type, "expand");
17557        assert_eq!(
17558            expand.local_id.as_deref(),
17559            Some("exp-001"),
17560            "expand localId should survive round-trip"
17561        );
17562        assert_eq!(
17563            expand.attrs.as_ref().unwrap()["title"],
17564            "Details",
17565            "expand title should survive round-trip"
17566        );
17567    }
17568
17569    #[test]
17570    fn nested_expand_localid_roundtrip() {
17571        let adf_json = r#"{"version":1,"type":"doc","content":[
17572          {"type":"nestedExpand","attrs":{"localId":"ne-001","title":"S"},"content":[
17573            {"type":"paragraph","content":[{"type":"text","text":"content"}]}
17574          ]}
17575        ]}"#;
17576        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17577        let md = adf_to_markdown(&doc).unwrap();
17578        assert!(
17579            md.contains(":::nested-expand{"),
17580            "should have directive: {md}"
17581        );
17582        assert!(md.contains("localId=ne-001"), "should have localId: {md}");
17583        let rt = markdown_to_adf(&md).unwrap();
17584        let ne = &rt.content[0];
17585        assert_eq!(ne.node_type, "nestedExpand");
17586        assert_eq!(ne.local_id.as_deref(), Some("ne-001"));
17587    }
17588
17589    #[test]
17590    fn nested_expand_localid_followed_by_content() {
17591        // Issue #412 reproducer: localId must not leak into following paragraph
17592        let adf_json = "{\
17593            \"version\":1,\"type\":\"doc\",\"content\":[\
17594              {\"type\":\"nestedExpand\",\"attrs\":{\"localId\":\"exp-001\",\"title\":\"S\"},\"content\":[\
17595                {\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}\
17596              ]},\
17597              {\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"after\"}]}\
17598            ]}";
17599        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17600        let md = adf_to_markdown(&doc).unwrap();
17601        let rt = markdown_to_adf(&md).unwrap();
17602        // nestedExpand should have localId
17603        let ne = &rt.content[0];
17604        assert_eq!(ne.node_type, "nestedExpand");
17605        assert_eq!(
17606            ne.local_id.as_deref(),
17607            Some("exp-001"),
17608            "nestedExpand should preserve localId"
17609        );
17610        // Following paragraph should contain "after", not "{localId=...}"
17611        let para = &rt.content[1];
17612        assert_eq!(para.node_type, "paragraph");
17613        let text = para.content.as_ref().unwrap()[0]
17614            .text
17615            .as_deref()
17616            .unwrap_or("");
17617        assert!(
17618            !text.contains("localId"),
17619            "following paragraph should not contain localId: {text}"
17620        );
17621        assert!(
17622            text.contains("after"),
17623            "following paragraph should contain 'after': {text}"
17624        );
17625    }
17626
17627    #[test]
17628    fn expand_localid_without_title() {
17629        let adf_json = r#"{"version":1,"type":"doc","content":[
17630          {"type":"expand","attrs":{"localId":"exp-002"},"content":[
17631            {"type":"paragraph","content":[{"type":"text","text":"no title"}]}
17632          ]}
17633        ]}"#;
17634        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17635        let md = adf_to_markdown(&doc).unwrap();
17636        assert!(
17637            md.contains(":::expand{localId=exp-002}"),
17638            "should have localId without title: {md}"
17639        );
17640        let rt = markdown_to_adf(&md).unwrap();
17641        assert_eq!(rt.content[0].local_id.as_deref(), Some("exp-002"));
17642    }
17643
17644    #[test]
17645    fn expand_localid_stripped() {
17646        let adf_json = r#"{"version":1,"type":"doc","content":[
17647          {"type":"expand","attrs":{"localId":"exp-001","title":"X"},"content":[
17648            {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17649          ]}
17650        ]}"#;
17651        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17652        let opts = RenderOptions {
17653            strip_local_ids: true,
17654        };
17655        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
17656        assert!(!md.contains("localId"), "localId should be stripped: {md}");
17657        assert!(
17658            md.contains(":::expand{title=\"X\"}"),
17659            "title should remain: {md}"
17660        );
17661    }
17662
17663    // ── Issue #444: top-level localId and parameters on expand ──
17664
17665    #[test]
17666    fn expand_top_level_localid_roundtrip() {
17667        // localId as a top-level field (not inside attrs) should survive round-trip
17668        let adf_json = r#"{"version":1,"type":"doc","content":[
17669          {"type":"expand","attrs":{"title":"My Section"},"localId":"abc-123","content":[
17670            {"type":"paragraph","content":[{"type":"text","text":"hello"}]}
17671          ]}
17672        ]}"#;
17673        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17674        assert_eq!(doc.content[0].local_id.as_deref(), Some("abc-123"));
17675        let md = adf_to_markdown(&doc).unwrap();
17676        assert!(
17677            md.contains("localId=abc-123"),
17678            "JFM should contain localId: {md}"
17679        );
17680        let rt = markdown_to_adf(&md).unwrap();
17681        let expand = &rt.content[0];
17682        assert_eq!(expand.node_type, "expand");
17683        assert_eq!(expand.local_id.as_deref(), Some("abc-123"));
17684        assert_eq!(
17685            expand.attrs.as_ref().unwrap()["title"],
17686            "My Section",
17687            "title should survive round-trip"
17688        );
17689    }
17690
17691    #[test]
17692    fn expand_parameters_roundtrip() {
17693        // parameters (macroMetadata) should survive round-trip
17694        let adf_json = r#"{"version":1,"type":"doc","content":[
17695          {"type":"expand","attrs":{"title":"Props"},"parameters":{"macroMetadata":{"macroId":{"value":"m-001"},"schemaVersion":{"value":"1"}}},"content":[
17696            {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17697          ]}
17698        ]}"#;
17699        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17700        assert!(doc.content[0].parameters.is_some());
17701        let md = adf_to_markdown(&doc).unwrap();
17702        assert!(md.contains("params="), "JFM should contain params: {md}");
17703        let rt = markdown_to_adf(&md).unwrap();
17704        let expand = &rt.content[0];
17705        let params = expand
17706            .parameters
17707            .as_ref()
17708            .expect("parameters should survive round-trip");
17709        assert_eq!(params["macroMetadata"]["macroId"]["value"], "m-001");
17710        assert_eq!(params["macroMetadata"]["schemaVersion"]["value"], "1");
17711    }
17712
17713    #[test]
17714    fn expand_localid_and_parameters_roundtrip() {
17715        // Issue #444: both localId and parameters on expand should survive round-trip
17716        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"}]}]}]}"#;
17717        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17718        let md = adf_to_markdown(&doc).unwrap();
17719        let rt = markdown_to_adf(&md).unwrap();
17720        let expand = &rt.content[0];
17721        assert_eq!(expand.node_type, "expand");
17722        assert_eq!(expand.local_id.as_deref(), Some("abc-123"));
17723        assert_eq!(expand.attrs.as_ref().unwrap()["title"], "My Section");
17724        let params = expand
17725            .parameters
17726            .as_ref()
17727            .expect("parameters should survive");
17728        assert_eq!(params["macroMetadata"]["macroId"]["value"], "macro-001");
17729        assert_eq!(params["macroMetadata"]["title"], "Page Properties");
17730    }
17731
17732    #[test]
17733    fn nested_expand_top_level_localid_and_parameters_roundtrip() {
17734        let adf_json = r#"{"version":1,"type":"doc","content":[
17735          {"type":"nestedExpand","attrs":{"title":"Nested"},"localId":"ne-100","parameters":{"macroMetadata":{"macroId":{"value":"nm-001"}}},"content":[
17736            {"type":"paragraph","content":[{"type":"text","text":"inner"}]}
17737          ]}
17738        ]}"#;
17739        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17740        let md = adf_to_markdown(&doc).unwrap();
17741        assert!(
17742            md.contains(":::nested-expand{"),
17743            "should use nested-expand: {md}"
17744        );
17745        assert!(md.contains("localId=ne-100"), "should have localId: {md}");
17746        assert!(md.contains("params="), "should have params: {md}");
17747        let rt = markdown_to_adf(&md).unwrap();
17748        let ne = &rt.content[0];
17749        assert_eq!(ne.node_type, "nestedExpand");
17750        assert_eq!(ne.local_id.as_deref(), Some("ne-100"));
17751        assert_eq!(
17752            ne.parameters.as_ref().unwrap()["macroMetadata"]["macroId"]["value"],
17753            "nm-001"
17754        );
17755    }
17756
17757    #[test]
17758    fn expand_top_level_localid_stripped() {
17759        // strip_local_ids should strip top-level localId too
17760        let adf_json = r#"{"version":1,"type":"doc","content":[
17761          {"type":"expand","attrs":{"title":"X"},"localId":"exp-strip","content":[
17762            {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17763          ]}
17764        ]}"#;
17765        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17766        let opts = RenderOptions {
17767            strip_local_ids: true,
17768        };
17769        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
17770        assert!(!md.contains("localId"), "localId should be stripped: {md}");
17771        assert!(
17772            md.contains(":::expand{title=\"X\"}"),
17773            "title should remain: {md}"
17774        );
17775    }
17776
17777    #[test]
17778    fn expand_parameters_without_localid() {
17779        // parameters without localId should work
17780        let adf_json = r#"{"version":1,"type":"doc","content":[
17781          {"type":"expand","attrs":{"title":"P"},"parameters":{"macroMetadata":{"macroId":{"value":"solo"}}},"content":[
17782            {"type":"paragraph","content":[{"type":"text","text":"data"}]}
17783          ]}
17784        ]}"#;
17785        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17786        let md = adf_to_markdown(&doc).unwrap();
17787        assert!(!md.contains("localId"), "no localId: {md}");
17788        assert!(md.contains("params="), "has params: {md}");
17789        let rt = markdown_to_adf(&md).unwrap();
17790        assert!(rt.content[0].local_id.is_none());
17791        assert_eq!(
17792            rt.content[0].parameters.as_ref().unwrap()["macroMetadata"]["macroId"]["value"],
17793            "solo"
17794        );
17795    }
17796
17797    #[test]
17798    fn expand_localid_without_parameters() {
17799        // top-level localId without parameters should work
17800        let adf_json = r#"{"version":1,"type":"doc","content":[
17801          {"type":"expand","attrs":{"title":"L"},"localId":"lid-only","content":[
17802            {"type":"paragraph","content":[{"type":"text","text":"txt"}]}
17803          ]}
17804        ]}"#;
17805        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17806        let md = adf_to_markdown(&doc).unwrap();
17807        assert!(md.contains("localId=lid-only"), "has localId: {md}");
17808        assert!(!md.contains("params="), "no params: {md}");
17809        let rt = markdown_to_adf(&md).unwrap();
17810        assert_eq!(rt.content[0].local_id.as_deref(), Some("lid-only"));
17811        assert!(rt.content[0].parameters.is_none());
17812    }
17813
17814    #[test]
17815    fn nested_panel_inside_panel() {
17816        let md = ":::panel{type=info}\n:::panel{type=warning}\nInner warning\n:::\n:::";
17817        let adf = markdown_to_adf(md).unwrap();
17818
17819        // Outer panel should exist
17820        assert_eq!(adf.content.len(), 1);
17821        assert_eq!(adf.content[0].node_type, "panel");
17822
17823        // Outer panel should contain an inner panel (not have it truncated)
17824        let panel_content = adf.content[0].content.as_ref().unwrap();
17825        assert!(
17826            panel_content.iter().any(|n| n.node_type == "panel"),
17827            "Outer panel should contain an inner panel, got: {:?}",
17828            panel_content
17829                .iter()
17830                .map(|n| &n.node_type)
17831                .collect::<Vec<_>>()
17832        );
17833    }
17834
17835    #[test]
17836    fn content_after_directive_table_is_preserved() {
17837        // Issue #361: content after a ::::table block was silently dropped
17838        let md = "\
17839## Before table
17840
17841::::table{layout=default}
17842:::tr
17843:::th{}
17844Cell
17845:::
17846:::
17847::::
17848
17849## After table
17850
17851Paragraph after.";
17852        let adf = markdown_to_adf(md).unwrap();
17853        let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
17854        assert_eq!(
17855            types,
17856            vec!["heading", "table", "heading", "paragraph"],
17857            "Content after table was dropped: got {types:?}"
17858        );
17859    }
17860
17861    #[test]
17862    fn paragraph_after_directive_table_is_preserved() {
17863        // Issue #361: minimal reproducer — paragraph after table
17864        let md = "\
17865::::table{layout=default}
17866:::tr
17867:::th{}
17868Header
17869:::
17870:::
17871::::
17872
17873Just a paragraph.";
17874        let adf = markdown_to_adf(md).unwrap();
17875        let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
17876        assert_eq!(
17877            types,
17878            vec!["table", "paragraph"],
17879            "Paragraph after table was dropped: got {types:?}"
17880        );
17881    }
17882
17883    #[test]
17884    fn extension_after_directive_table_is_preserved() {
17885        // Issue #361: extension after table
17886        let md = "\
17887::::table{layout=default}
17888:::tr
17889:::th{}
17890Header
17891:::
17892:::
17893::::
17894
17895::extension{type=com.atlassian.confluence.macro.core key=toc}";
17896        let adf = markdown_to_adf(md).unwrap();
17897        let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
17898        assert_eq!(
17899            types,
17900            vec!["table", "extension"],
17901            "Extension after table was dropped: got {types:?}"
17902        );
17903    }
17904
17905    #[test]
17906    fn multiple_blocks_after_directive_table() {
17907        // Issue #361: multiple blocks after table, including another table
17908        let md = "\
17909## Heading 1
17910
17911::::table{layout=default}
17912:::tr
17913:::td{}
17914A
17915:::
17916:::td{}
17917B
17918:::
17919:::
17920::::
17921
17922## Heading 2
17923
17924Some text.
17925
17926---
17927
17928::::table{layout=default}
17929:::tr
17930:::th{}
17931C
17932:::
17933:::
17934::::
17935
17936## Heading 3";
17937        let adf = markdown_to_adf(md).unwrap();
17938        let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
17939        assert_eq!(
17940            types,
17941            vec![
17942                "heading",
17943                "table",
17944                "heading",
17945                "paragraph",
17946                "rule",
17947                "table",
17948                "heading"
17949            ],
17950            "Content after tables was dropped: got {types:?}"
17951        );
17952    }
17953
17954    // ── Table caption tests (issue #382) ────────────────────────────
17955
17956    #[test]
17957    fn adf_table_caption_to_markdown() {
17958        let doc = AdfDocument {
17959            version: 1,
17960            doc_type: "doc".to_string(),
17961            content: vec![AdfNode::table(vec![
17962                AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
17963                    AdfNode::text("cell"),
17964                ])])]),
17965                AdfNode::caption(vec![AdfNode::text("Table caption")]),
17966            ])],
17967        };
17968        let md = adf_to_markdown(&doc).unwrap();
17969        assert!(
17970            md.contains("::::table"),
17971            "table with caption must use directive form"
17972        );
17973        assert!(
17974            md.contains(":::caption"),
17975            "caption directive missing, got: {md}"
17976        );
17977        assert!(
17978            md.contains("Table caption"),
17979            "caption text missing, got: {md}"
17980        );
17981    }
17982
17983    #[test]
17984    fn directive_table_caption_parses() {
17985        let md = "::::table\n:::tr\n:::td\ncell\n:::\n:::\n:::caption\nTable caption\n:::\n::::\n";
17986        let doc = markdown_to_adf(md).unwrap();
17987        let table = &doc.content[0];
17988        assert_eq!(table.node_type, "table");
17989        let children = table.content.as_ref().unwrap();
17990        assert_eq!(children.len(), 2, "expected row + caption");
17991        assert_eq!(children[0].node_type, "tableRow");
17992        assert_eq!(children[1].node_type, "caption");
17993        let caption_content = children[1].content.as_ref().unwrap();
17994        assert_eq!(caption_content[0].text.as_deref(), Some("Table caption"));
17995    }
17996
17997    #[test]
17998    fn table_caption_round_trip_from_adf_json() {
17999        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
18000          {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
18001          {"type":"caption","content":[{"type":"text","text":"Table caption"}]}
18002        ]}]}"#;
18003        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18004        let md = adf_to_markdown(&doc).unwrap();
18005        assert!(md.contains("Table caption"), "caption text lost in ADF→JFM");
18006        let round_tripped = markdown_to_adf(&md).unwrap();
18007        let children = round_tripped.content[0].content.as_ref().unwrap();
18008        let caption = children.iter().find(|n| n.node_type == "caption");
18009        assert!(caption.is_some(), "caption lost on round-trip");
18010        let caption_text = caption.unwrap().content.as_ref().unwrap();
18011        assert_eq!(caption_text[0].text.as_deref(), Some("Table caption"));
18012    }
18013
18014    #[test]
18015    fn table_caption_with_inline_marks_round_trips() {
18016        let doc = AdfDocument {
18017            version: 1,
18018            doc_type: "doc".to_string(),
18019            content: vec![AdfNode::table(vec![
18020                AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
18021                    AdfNode::text("data"),
18022                ])])]),
18023                AdfNode::caption(vec![
18024                    AdfNode::text("Caption with "),
18025                    AdfNode::text_with_marks("bold", vec![AdfMark::strong()]),
18026                ]),
18027            ])],
18028        };
18029        let md = adf_to_markdown(&doc).unwrap();
18030        assert!(md.contains("**bold**"), "bold mark missing in caption");
18031        let round_tripped = markdown_to_adf(&md).unwrap();
18032        let caption = round_tripped.content[0]
18033            .content
18034            .as_ref()
18035            .unwrap()
18036            .iter()
18037            .find(|n| n.node_type == "caption")
18038            .expect("caption node missing after round-trip");
18039        let inlines = caption.content.as_ref().unwrap();
18040        let bold_node = inlines.iter().find(|n| {
18041            n.marks
18042                .as_ref()
18043                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
18044        });
18045        assert!(bold_node.is_some(), "bold mark lost in caption round-trip");
18046    }
18047
18048    // ── table caption localId tests (issue #524) ──────────────────────
18049
18050    #[test]
18051    fn table_caption_localid_roundtrip() {
18052        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
18053          {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
18054          {"type":"caption","attrs":{"localId":"abcdef123456"},"content":[{"type":"text","text":"Table with localId"}]}
18055        ]}]}"#;
18056        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18057        let md = adf_to_markdown(&doc).unwrap();
18058        assert!(
18059            md.contains("localId=abcdef123456"),
18060            "table caption localId should appear in markdown: {md}"
18061        );
18062        let rt = markdown_to_adf(&md).unwrap();
18063        let caption = rt.content[0]
18064            .content
18065            .as_ref()
18066            .unwrap()
18067            .iter()
18068            .find(|n| n.node_type == "caption")
18069            .expect("caption should survive round-trip");
18070        assert_eq!(
18071            caption.attrs.as_ref().unwrap()["localId"],
18072            "abcdef123456",
18073            "table caption localId should round-trip"
18074        );
18075    }
18076
18077    #[test]
18078    fn table_caption_without_localid_unchanged() {
18079        let md = "::::table\n:::tr\n:::td\ncell\n:::\n:::\n:::caption\nPlain caption\n:::\n::::\n";
18080        let doc = markdown_to_adf(md).unwrap();
18081        let caption = doc.content[0]
18082            .content
18083            .as_ref()
18084            .unwrap()
18085            .iter()
18086            .find(|n| n.node_type == "caption")
18087            .unwrap();
18088        assert!(
18089            caption.attrs.is_none(),
18090            "table caption without localId should not gain attrs"
18091        );
18092        let md2 = adf_to_markdown(&doc).unwrap();
18093        assert!(!md2.contains("localId"), "no localId should appear: {md2}");
18094    }
18095
18096    #[test]
18097    fn table_caption_localid_stripped_when_option_set() {
18098        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
18099          {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
18100          {"type":"caption","attrs":{"localId":"abcdef123456"},"content":[{"type":"text","text":"Stripped"}]}
18101        ]}]}"#;
18102        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18103        let opts = RenderOptions {
18104            strip_local_ids: true,
18105            ..Default::default()
18106        };
18107        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
18108        assert!(
18109            !md.contains("localId"),
18110            "table caption localId should be stripped: {md}"
18111        );
18112    }
18113
18114    #[test]
18115    #[test]
18116    fn tablecell_empty_attrs_preserved_on_roundtrip() {
18117        // Issue #385: tableCell with empty attrs:{} dropped on round-trip
18118        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"}]}]}]}]}]}"#;
18119        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18120        let md = adf_to_markdown(&doc).unwrap();
18121        let round_tripped = markdown_to_adf(&md).unwrap();
18122        let rows = round_tripped.content[0].content.as_ref().unwrap();
18123        let cell = &rows[0].content.as_ref().unwrap()[0];
18124        assert!(
18125            cell.attrs.is_some(),
18126            "tableCell attrs should be preserved, got None"
18127        );
18128        assert_eq!(
18129            cell.attrs.as_ref().unwrap(),
18130            &serde_json::json!({}),
18131            "tableCell attrs should be an empty object"
18132        );
18133    }
18134
18135    #[test]
18136    fn tablecell_empty_attrs_serialized_in_json() {
18137        // Issue #385: ensure the serialized JSON includes "attrs":{}
18138        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"}]}]}]}]}]}"#;
18139        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18140        let md = adf_to_markdown(&doc).unwrap();
18141        let round_tripped = markdown_to_adf(&md).unwrap();
18142        let json = serde_json::to_string(&round_tripped).unwrap();
18143        assert!(
18144            json.contains(r#""attrs":{}"#),
18145            "serialized JSON should contain \"attrs\":{{}}, got: {json}"
18146        );
18147    }
18148
18149    #[test]
18150    fn tablecell_empty_attrs_renders_braces_in_markdown() {
18151        // Issue #385: tableCell with empty attrs should render {} prefix in pipe tables
18152        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"}]}]}]}]}]}"#;
18153        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18154        let md = adf_to_markdown(&doc).unwrap();
18155        // Cell with attrs:{} should have {} prefix, cell without attrs should not
18156        assert!(
18157            md.contains("{} hello"),
18158            "cell with empty attrs should render '{{}} hello', got: {md}"
18159        );
18160        assert!(
18161            !md.contains("{} world"),
18162            "cell without attrs should not render '{{}}', got: {md}"
18163        );
18164    }
18165
18166    #[test]
18167    fn tablecell_no_attrs_unchanged_on_roundtrip() {
18168        // Ensure tableCell without attrs stays without attrs
18169        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"}]}]}]}]}]}"#;
18170        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18171        let md = adf_to_markdown(&doc).unwrap();
18172        let round_tripped = markdown_to_adf(&md).unwrap();
18173        let rows = round_tripped.content[0].content.as_ref().unwrap();
18174        let cell = &rows[0].content.as_ref().unwrap()[0];
18175        assert!(
18176            cell.attrs.is_none(),
18177            "tableCell without attrs should stay None, got: {:?}",
18178            cell.attrs
18179        );
18180    }
18181
18182    #[test]
18183    fn tablecell_nonempty_attrs_preserved_on_roundtrip() {
18184        // Ensure tableCell with non-empty attrs still works
18185        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"}]}]}]}]}]}"##;
18186        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18187        let md = adf_to_markdown(&doc).unwrap();
18188        let round_tripped = markdown_to_adf(&md).unwrap();
18189        let rows = round_tripped.content[0].content.as_ref().unwrap();
18190        let cell = &rows[1].content.as_ref().unwrap()[0];
18191        let attrs = cell.attrs.as_ref().unwrap();
18192        assert_eq!(attrs["background"], "#DEEBFF");
18193        assert_eq!(attrs["colspan"], 2);
18194    }
18195
18196    #[test]
18197    fn pipe_table_not_used_when_caption_present() {
18198        let doc = AdfDocument {
18199            version: 1,
18200            doc_type: "doc".to_string(),
18201            content: vec![AdfNode::table(vec![
18202                AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
18203                    AdfNode::text("H"),
18204                ])])]),
18205                AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
18206                    AdfNode::text("D"),
18207                ])])]),
18208                AdfNode::caption(vec![AdfNode::text("cap")]),
18209            ])],
18210        };
18211        let md = adf_to_markdown(&doc).unwrap();
18212        assert!(
18213            md.contains("::::table"),
18214            "pipe syntax should not be used when caption is present"
18215        );
18216    }
18217
18218    // ── Issue #402: ordered-list-like text in list item hardBreak ──
18219
18220    #[test]
18221    fn hardbreak_with_ordered_marker_in_bullet_item_roundtrips() {
18222        // Issue #402: text starting with "2. " after a hardBreak inside a
18223        // bullet list item must not be re-parsed as a new ordered list.
18224        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18225          {"type":"listItem","content":[{"type":"paragraph","content":[
18226            {"type":"text","text":"1. First item"},
18227            {"type":"hardBreak"},
18228            {"type":"text","text":"2. Honouring existing commitments"}
18229          ]}]}
18230        ]}]}"#;
18231        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18232        let md = adf_to_markdown(&doc).unwrap();
18233
18234        // The continuation line must be indented so it stays within the list item.
18235        assert!(
18236            md.contains("  2. Honouring"),
18237            "Continuation line should be indented, got:\n{md}"
18238        );
18239
18240        // Round-trip back to ADF
18241        let rt = markdown_to_adf(&md).unwrap();
18242        let list = &rt.content[0];
18243        assert_eq!(list.node_type, "bulletList");
18244        let items = list.content.as_ref().unwrap();
18245        assert_eq!(
18246            items.len(),
18247            1,
18248            "Should be one list item, got {}",
18249            items.len()
18250        );
18251
18252        let para = &items[0].content.as_ref().unwrap()[0];
18253        let inlines = para.content.as_ref().unwrap();
18254        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18255        assert_eq!(
18256            types,
18257            vec!["text", "hardBreak", "text"],
18258            "Expected text+hardBreak+text, got {types:?}"
18259        );
18260        assert_eq!(
18261            inlines[2].text.as_deref().unwrap(),
18262            "2. Honouring existing commitments"
18263        );
18264    }
18265
18266    #[test]
18267    fn hardbreak_with_ordered_marker_in_ordered_item_roundtrips() {
18268        // Same as above but inside an ordered list.
18269        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
18270          {"type":"listItem","content":[{"type":"paragraph","content":[
18271            {"type":"text","text":"Introduction  "},
18272            {"type":"hardBreak"},
18273            {"type":"text","text":"3. Third point"}
18274          ]}]}
18275        ]}]}"#;
18276        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18277        let md = adf_to_markdown(&doc).unwrap();
18278        let rt = markdown_to_adf(&md).unwrap();
18279
18280        let list = &rt.content[0];
18281        assert_eq!(list.node_type, "orderedList");
18282        let items = list.content.as_ref().unwrap();
18283        assert_eq!(items.len(), 1);
18284
18285        let para = &items[0].content.as_ref().unwrap()[0];
18286        let inlines = para.content.as_ref().unwrap();
18287        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18288        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18289        assert_eq!(inlines[2].text.as_deref().unwrap(), "3. Third point");
18290    }
18291
18292    #[test]
18293    fn hardbreak_with_bullet_marker_in_bullet_item_roundtrips() {
18294        // Text starting with "- " after a hardBreak must not become a nested bullet list.
18295        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18296          {"type":"listItem","content":[{"type":"paragraph","content":[
18297            {"type":"text","text":"Header  "},
18298            {"type":"hardBreak"},
18299            {"type":"text","text":"- not a sub-item"}
18300          ]}]}
18301        ]}]}"#;
18302        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18303        let md = adf_to_markdown(&doc).unwrap();
18304        let rt = markdown_to_adf(&md).unwrap();
18305
18306        let list = &rt.content[0];
18307        assert_eq!(list.node_type, "bulletList");
18308        let items = list.content.as_ref().unwrap();
18309        assert_eq!(
18310            items.len(),
18311            1,
18312            "Should be one list item, not {}",
18313            items.len()
18314        );
18315
18316        let para = &items[0].content.as_ref().unwrap()[0];
18317        let inlines = para.content.as_ref().unwrap();
18318        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18319        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18320        assert_eq!(inlines[2].text.as_deref().unwrap(), "- not a sub-item");
18321    }
18322
18323    #[test]
18324    fn hardbreak_continuation_followed_by_sub_list() {
18325        // A hardBreak continuation line followed by a real sub-list.
18326        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18327          {"type":"listItem","content":[
18328            {"type":"paragraph","content":[
18329              {"type":"text","text":"Main item  "},
18330              {"type":"hardBreak"},
18331              {"type":"text","text":"continued here"}
18332            ]},
18333            {"type":"bulletList","content":[
18334              {"type":"listItem","content":[{"type":"paragraph","content":[
18335                {"type":"text","text":"sub-item"}
18336              ]}]}
18337            ]}
18338          ]}
18339        ]}]}"#;
18340        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18341        let md = adf_to_markdown(&doc).unwrap();
18342        let rt = markdown_to_adf(&md).unwrap();
18343
18344        let list = &rt.content[0];
18345        let items = list.content.as_ref().unwrap();
18346        assert_eq!(items.len(), 1);
18347
18348        let item_content = items[0].content.as_ref().unwrap();
18349        assert_eq!(item_content.len(), 2, "Expected paragraph + nested list");
18350        assert_eq!(item_content[0].node_type, "paragraph");
18351        assert_eq!(item_content[1].node_type, "bulletList");
18352
18353        // Check the paragraph has hardBreak
18354        let inlines = item_content[0].content.as_ref().unwrap();
18355        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18356        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18357    }
18358
18359    #[test]
18360    fn multiple_hardbreaks_with_numbered_text_roundtrip() {
18361        // Multiple hardBreaks where each continuation resembles an ordered list.
18362        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18363          {"type":"listItem","content":[{"type":"paragraph","content":[
18364            {"type":"text","text":"Preamble  "},
18365            {"type":"hardBreak"},
18366            {"type":"text","text":"1. Alpha  "},
18367            {"type":"hardBreak"},
18368            {"type":"text","text":"2. Bravo"}
18369          ]}]}
18370        ]}]}"#;
18371        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18372        let md = adf_to_markdown(&doc).unwrap();
18373        let rt = markdown_to_adf(&md).unwrap();
18374
18375        let items = rt.content[0].content.as_ref().unwrap();
18376        assert_eq!(items.len(), 1);
18377
18378        let inlines = items[0].content.as_ref().unwrap()[0]
18379            .content
18380            .as_ref()
18381            .unwrap();
18382        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18383        assert_eq!(
18384            types,
18385            vec!["text", "hardBreak", "text", "hardBreak", "text"]
18386        );
18387    }
18388
18389    #[test]
18390    fn trailing_hardbreak_in_bullet_item_roundtrips() {
18391        // A hardBreak as the last inline node with no text after it.
18392        // Exercises the `break` path in the continuation loop and the
18393        // empty-line rendering branch.
18394        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18395          {"type":"listItem","content":[{"type":"paragraph","content":[
18396            {"type":"text","text":"ends with break"},
18397            {"type":"hardBreak"}
18398          ]}]}
18399        ]}]}"#;
18400        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18401        let md = adf_to_markdown(&doc).unwrap();
18402        let rt = markdown_to_adf(&md).unwrap();
18403
18404        let list = &rt.content[0];
18405        assert_eq!(list.node_type, "bulletList");
18406        let inlines = list.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0]
18407            .content
18408            .as_ref()
18409            .unwrap();
18410        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18411        assert_eq!(types, vec!["text", "hardBreak"]);
18412    }
18413
18414    #[test]
18415    fn trailing_hardbreak_in_ordered_item_roundtrips() {
18416        // Same as above but in an ordered list, covering the ordered-list
18417        // continuation `break` path.
18418        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
18419          {"type":"listItem","content":[{"type":"paragraph","content":[
18420            {"type":"text","text":"ends with break"},
18421            {"type":"hardBreak"}
18422          ]}]}
18423        ]}]}"#;
18424        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18425        let md = adf_to_markdown(&doc).unwrap();
18426        let rt = markdown_to_adf(&md).unwrap();
18427
18428        let list = &rt.content[0];
18429        assert_eq!(list.node_type, "orderedList");
18430        let inlines = list.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0]
18431            .content
18432            .as_ref()
18433            .unwrap();
18434        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18435        assert_eq!(types, vec!["text", "hardBreak"]);
18436    }
18437
18438    #[test]
18439    fn trailing_space_hardbreak_continuation_in_bullet_item() {
18440        // Exercises the `ends_with("  ")` path in `has_trailing_hard_break`
18441        // by parsing hand-written markdown that uses trailing-space style
18442        // hardBreaks instead of backslash style.
18443        let md = "- first line  \n  2. continued\n";
18444        let doc = markdown_to_adf(md).unwrap();
18445
18446        let list = &doc.content[0];
18447        assert_eq!(list.node_type, "bulletList");
18448        let items = list.content.as_ref().unwrap();
18449        assert_eq!(
18450            items.len(),
18451            1,
18452            "Should be one list item, got {}",
18453            items.len()
18454        );
18455
18456        let para = &items[0].content.as_ref().unwrap()[0];
18457        let inlines = para.content.as_ref().unwrap();
18458        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18459        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18460        assert_eq!(inlines[2].text.as_deref().unwrap(), "2. continued");
18461    }
18462
18463    #[test]
18464    fn trailing_space_hardbreak_continuation_in_ordered_item() {
18465        // Same as above but for ordered list, exercising the trailing-space
18466        // path in the ordered-list continuation loop.
18467        let md = "1. first line  \n  - continued\n";
18468        let doc = markdown_to_adf(md).unwrap();
18469
18470        let list = &doc.content[0];
18471        assert_eq!(list.node_type, "orderedList");
18472        let items = list.content.as_ref().unwrap();
18473        assert_eq!(
18474            items.len(),
18475            1,
18476            "Should be one list item, got {}",
18477            items.len()
18478        );
18479
18480        let para = &items[0].content.as_ref().unwrap()[0];
18481        let inlines = para.content.as_ref().unwrap();
18482        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18483        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18484        assert_eq!(inlines[2].text.as_deref().unwrap(), "- continued");
18485    }
18486
18487    #[test]
18488    fn multi_paragraph_list_item_with_ordered_marker_roundtrips() {
18489        // Issue #402 comment: a listItem with a second paragraph starting
18490        // with "2. " must not become a separate orderedList.
18491        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18492          {"type":"listItem","content":[
18493            {"type":"paragraph","content":[{"type":"text","text":"some preamble"}]},
18494            {"type":"paragraph","content":[{"type":"text","text":"2. Honouring existing commitments"}]}
18495          ]}
18496        ]}]}"#;
18497        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18498        let md = adf_to_markdown(&doc).unwrap();
18499        let rt = markdown_to_adf(&md).unwrap();
18500
18501        assert_eq!(rt.content.len(), 1, "Should be one top-level block");
18502        let list = &rt.content[0];
18503        assert_eq!(list.node_type, "bulletList");
18504        let items = list.content.as_ref().unwrap();
18505        assert_eq!(items.len(), 1);
18506        let item_content = items[0].content.as_ref().unwrap();
18507        assert_eq!(
18508            item_content.len(),
18509            2,
18510            "Expected 2 paragraphs inside the list item, got {}",
18511            item_content.len()
18512        );
18513        assert_eq!(item_content[0].node_type, "paragraph");
18514        assert_eq!(item_content[1].node_type, "paragraph");
18515        let text = item_content[1].content.as_ref().unwrap()[0]
18516            .text
18517            .as_deref()
18518            .unwrap();
18519        assert_eq!(text, "2. Honouring existing commitments");
18520    }
18521
18522    #[test]
18523    fn multi_paragraph_list_item_with_bullet_marker_roundtrips() {
18524        // Paragraph starting with "- " inside a list item.
18525        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18526          {"type":"listItem","content":[
18527            {"type":"paragraph","content":[{"type":"text","text":"preamble"}]},
18528            {"type":"paragraph","content":[{"type":"text","text":"- not a sub-item"}]}
18529          ]}
18530        ]}]}"#;
18531        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18532        let md = adf_to_markdown(&doc).unwrap();
18533        let rt = markdown_to_adf(&md).unwrap();
18534
18535        let items = rt.content[0].content.as_ref().unwrap();
18536        assert_eq!(items.len(), 1);
18537        let item_content = items[0].content.as_ref().unwrap();
18538        assert_eq!(item_content.len(), 2);
18539        assert_eq!(item_content[1].node_type, "paragraph");
18540        let text = item_content[1].content.as_ref().unwrap()[0]
18541            .text
18542            .as_deref()
18543            .unwrap();
18544        assert_eq!(text, "- not a sub-item");
18545    }
18546
18547    #[test]
18548    fn backslash_escape_in_inline_text() {
18549        // Verify that `\. ` is unescaped to `. ` in inline parsing.
18550        let nodes = parse_inline(r"2\. text");
18551        assert_eq!(nodes.len(), 1, "Should be one text node");
18552        assert_eq!(nodes[0].text.as_deref().unwrap(), "2. text");
18553    }
18554
18555    #[test]
18556    fn escape_list_marker_ordered() {
18557        assert_eq!(escape_list_marker("2. text"), r"2\. text");
18558        assert_eq!(escape_list_marker("10. tenth"), r"10\. tenth");
18559    }
18560
18561    #[test]
18562    fn escape_list_marker_bullet() {
18563        assert_eq!(escape_list_marker("- text"), r"\- text");
18564        assert_eq!(escape_list_marker("* text"), r"\* text");
18565        assert_eq!(escape_list_marker("+ text"), r"\+ text");
18566    }
18567
18568    #[test]
18569    fn escape_list_marker_plain() {
18570        assert_eq!(escape_list_marker("plain text"), "plain text");
18571        assert_eq!(escape_list_marker("no. marker"), "no. marker");
18572    }
18573
18574    #[test]
18575    fn escape_emoji_shortcodes_basic() {
18576        assert_eq!(escape_emoji_shortcodes(":fire:"), r"\:fire:");
18577        assert_eq!(
18578            escape_emoji_shortcodes("hello :wave: world"),
18579            r"hello \:wave: world"
18580        );
18581    }
18582
18583    #[test]
18584    fn escape_emoji_shortcodes_double_colon() {
18585        // Only the colon that starts `:Active:` needs escaping
18586        assert_eq!(
18587            escape_emoji_shortcodes("Status::Active::Running"),
18588            r"Status:\:Active::Running"
18589        );
18590    }
18591
18592    #[test]
18593    fn escape_emoji_shortcodes_no_match() {
18594        // Lone colons, numeric-only between colons like 10:30
18595        assert_eq!(escape_emoji_shortcodes("Time is 10:30"), "Time is 10:30");
18596        assert_eq!(escape_emoji_shortcodes("no colons here"), "no colons here");
18597        assert_eq!(escape_emoji_shortcodes("trailing:"), "trailing:");
18598        assert_eq!(escape_emoji_shortcodes(":"), ":");
18599    }
18600
18601    #[test]
18602    fn escape_emoji_shortcodes_mixed() {
18603        assert_eq!(
18604            escape_emoji_shortcodes("Alert :fire: on pod:pod42"),
18605            r"Alert \:fire: on pod:pod42"
18606        );
18607    }
18608
18609    #[test]
18610    fn escape_emoji_shortcodes_unicode() {
18611        // Issue #552: Unicode alphanumeric chars must be escaped to match
18612        // `try_parse_emoji_shortcode`, which uses `is_alphanumeric` (not the
18613        // ASCII-only variant).  Without this, `:Café:` rendered un-escaped
18614        // would be re-parsed as an emoji on round-trip.
18615        assert_eq!(escape_emoji_shortcodes(":Café:"), r"\:Café:");
18616        assert_eq!(escape_emoji_shortcodes(":über:"), r"\:über:");
18617        assert_eq!(escape_emoji_shortcodes(":配置:"), r"\:配置:");
18618        assert_eq!(
18619            escape_emoji_shortcodes("ZBC::配置::Production"),
18620            r"ZBC:\:配置::Production"
18621        );
18622    }
18623
18624    #[test]
18625    fn escape_emoji_shortcodes_mixed_script_name() {
18626        // Issue #552: A name that mixes ASCII and Unicode alphanumerics is
18627        // still a single valid shortcode under `is_alphanumeric`.
18628        assert_eq!(escape_emoji_shortcodes(":abc配置:"), r"\:abc配置:");
18629        assert_eq!(escape_emoji_shortcodes(":配置abc:"), r"\:配置abc:");
18630    }
18631
18632    #[test]
18633    fn escape_emoji_shortcodes_unicode_followed_by_non_colon() {
18634        // `:Café world:` — `Café` is alphanumeric but the terminator is a
18635        // space, not `:`, so the `after + name_end < text.len()` path is
18636        // exercised but the final `== b':'` check bails out and nothing
18637        // gets escaped.  Guards the negative branch of the predicate.
18638        assert_eq!(escape_emoji_shortcodes(":Café world:"), ":Café world:");
18639    }
18640
18641    #[test]
18642    fn escape_emoji_shortcodes_name_runs_to_end() {
18643        // Colon followed by alphanumerics to end of string: `.find(...)` returns
18644        // `None`, so `name_end` falls back to the full remaining length via
18645        // `map_or`.  The `after + name_end < text.len()` check then fails and
18646        // nothing is escaped.  Exercises the `map_or` default branch for both
18647        // ASCII and Unicode names.
18648        assert_eq!(escape_emoji_shortcodes(":abc"), ":abc");
18649        assert_eq!(escape_emoji_shortcodes(":配置"), ":配置");
18650    }
18651
18652    #[test]
18653    fn unicode_shortcode_pattern_text_round_trips_as_text() {
18654        // Issue #552: A text node containing `:Café:` must round-trip as text,
18655        // not be split into text + emoji nodes.
18656        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18657          {"type":"text","text":"Visit :Café: today"}
18658        ]}]}"#;
18659        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18660
18661        let md = adf_to_markdown(&doc).unwrap();
18662        let round_tripped = markdown_to_adf(&md).unwrap();
18663        let content = round_tripped.content[0].content.as_ref().unwrap();
18664
18665        assert_eq!(
18666            content.len(),
18667            1,
18668            "should be a single text node, got: {content:?}"
18669        );
18670        assert_eq!(content[0].node_type, "text");
18671        assert_eq!(content[0].text.as_deref().unwrap(), "Visit :Café: today");
18672    }
18673
18674    #[test]
18675    fn unicode_double_colon_pattern_text_round_trips() {
18676        // Issue #552: `ZBC::配置::Production` should round-trip without splitting.
18677        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18678          {"type":"text","text":"Use ZBC::配置::Production for prod"}
18679        ]}]}"#;
18680        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18681
18682        let md = adf_to_markdown(&doc).unwrap();
18683        let round_tripped = markdown_to_adf(&md).unwrap();
18684        let content = round_tripped.content[0].content.as_ref().unwrap();
18685
18686        assert_eq!(
18687            content.len(),
18688            1,
18689            "should be a single text node, got: {content:?}"
18690        );
18691        assert_eq!(
18692            content[0].text.as_deref().unwrap(),
18693            "Use ZBC::配置::Production for prod"
18694        );
18695    }
18696
18697    #[test]
18698    fn merge_adjacent_text_nodes() {
18699        let mut nodes = vec![AdfNode::text("a"), AdfNode::text("b"), AdfNode::text("c")];
18700        merge_adjacent_text(&mut nodes);
18701        assert_eq!(nodes.len(), 1);
18702        assert_eq!(nodes[0].text.as_deref().unwrap(), "abc");
18703    }
18704
18705    // ── Issue #455: text after hardBreak in paragraph re-parsed as list ──
18706
18707    #[test]
18708    fn issue_455_paragraph_hardbreak_ordered_marker_roundtrips() {
18709        // Issue #455: "1. text" after a hardBreak in a paragraph must not
18710        // become an ordered list.
18711        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18712          {"type":"text","text":"Introduction: "},
18713          {"type":"hardBreak"},
18714          {"type":"text","text":"1. This text follows a hardBreak"}
18715        ]}]}"#;
18716        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18717        let md = adf_to_markdown(&doc).unwrap();
18718        let rt = markdown_to_adf(&md).unwrap();
18719
18720        assert_eq!(rt.content.len(), 1, "Should remain one block");
18721        assert_eq!(rt.content[0].node_type, "paragraph");
18722        let inlines = rt.content[0].content.as_ref().unwrap();
18723        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18724        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18725        assert_eq!(
18726            inlines[2].text.as_deref(),
18727            Some("1. This text follows a hardBreak")
18728        );
18729    }
18730
18731    #[test]
18732    fn issue_455_paragraph_hardbreak_bullet_marker_roundtrips() {
18733        // Issue #455 variant: "- text" after a hardBreak in a paragraph.
18734        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18735          {"type":"text","text":"Intro"},
18736          {"type":"hardBreak"},
18737          {"type":"text","text":"- not a list item"}
18738        ]}]}"#;
18739        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18740        let md = adf_to_markdown(&doc).unwrap();
18741        let rt = markdown_to_adf(&md).unwrap();
18742
18743        assert_eq!(rt.content.len(), 1);
18744        assert_eq!(rt.content[0].node_type, "paragraph");
18745        let inlines = rt.content[0].content.as_ref().unwrap();
18746        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18747        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18748        assert_eq!(inlines[2].text.as_deref(), Some("- not a list item"));
18749    }
18750
18751    #[test]
18752    fn issue_455_paragraph_hardbreak_heading_marker_roundtrips() {
18753        // Issue #455 variant: "# text" after a hardBreak in a paragraph.
18754        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18755          {"type":"text","text":"Intro"},
18756          {"type":"hardBreak"},
18757          {"type":"text","text":"# not a heading"}
18758        ]}]}"##;
18759        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18760        let md = adf_to_markdown(&doc).unwrap();
18761        let rt = markdown_to_adf(&md).unwrap();
18762
18763        assert_eq!(rt.content.len(), 1);
18764        assert_eq!(rt.content[0].node_type, "paragraph");
18765        let inlines = rt.content[0].content.as_ref().unwrap();
18766        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18767        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18768        assert_eq!(inlines[2].text.as_deref(), Some("# not a heading"));
18769    }
18770
18771    #[test]
18772    fn issue_455_paragraph_hardbreak_blockquote_marker_roundtrips() {
18773        // Issue #455 variant: "> text" after a hardBreak in a paragraph.
18774        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18775          {"type":"text","text":"Intro"},
18776          {"type":"hardBreak"},
18777          {"type":"text","text":"> not a blockquote"}
18778        ]}]}"#;
18779        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18780        let md = adf_to_markdown(&doc).unwrap();
18781        let rt = markdown_to_adf(&md).unwrap();
18782
18783        assert_eq!(rt.content.len(), 1);
18784        assert_eq!(rt.content[0].node_type, "paragraph");
18785        let inlines = rt.content[0].content.as_ref().unwrap();
18786        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18787        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18788        assert_eq!(inlines[2].text.as_deref(), Some("> not a blockquote"));
18789    }
18790
18791    #[test]
18792    fn issue_455_paragraph_multiple_hardbreaks_with_ordered_markers() {
18793        // Multiple hardBreaks in a paragraph, each followed by "N. text".
18794        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18795          {"type":"text","text":"Preamble"},
18796          {"type":"hardBreak"},
18797          {"type":"text","text":"1. First"},
18798          {"type":"hardBreak"},
18799          {"type":"text","text":"2. Second"},
18800          {"type":"hardBreak"},
18801          {"type":"text","text":"3. Third"}
18802        ]}]}"#;
18803        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18804        let md = adf_to_markdown(&doc).unwrap();
18805        let rt = markdown_to_adf(&md).unwrap();
18806
18807        assert_eq!(rt.content.len(), 1);
18808        assert_eq!(rt.content[0].node_type, "paragraph");
18809        let inlines = rt.content[0].content.as_ref().unwrap();
18810        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18811        assert_eq!(
18812            types,
18813            vec![
18814                "text",
18815                "hardBreak",
18816                "text",
18817                "hardBreak",
18818                "text",
18819                "hardBreak",
18820                "text"
18821            ]
18822        );
18823        assert_eq!(inlines[2].text.as_deref(), Some("1. First"));
18824        assert_eq!(inlines[4].text.as_deref(), Some("2. Second"));
18825        assert_eq!(inlines[6].text.as_deref(), Some("3. Third"));
18826    }
18827
18828    #[test]
18829    fn issue_455_paragraph_hardbreak_jfm_indentation() {
18830        // Verify that ADF→JFM output indents continuation lines.
18831        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18832          {"type":"text","text":"Intro"},
18833          {"type":"hardBreak"},
18834          {"type":"text","text":"1. continued"}
18835        ]}]}"#;
18836        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18837        let md = adf_to_markdown(&doc).unwrap();
18838        assert!(
18839            md.contains("Intro\\\n  1. continued"),
18840            "Continuation should be 2-space-indented, got: {md:?}"
18841        );
18842    }
18843
18844    #[test]
18845    fn issue_455_paragraph_hardbreak_from_jfm() {
18846        // Verify that JFM with 2-space-indented continuation is parsed
18847        // back as a single paragraph with hardBreak.
18848        let md = "Intro\\\n  1. This is continuation text\n";
18849        let doc = markdown_to_adf(md).unwrap();
18850
18851        assert_eq!(doc.content.len(), 1);
18852        assert_eq!(doc.content[0].node_type, "paragraph");
18853        let inlines = doc.content[0].content.as_ref().unwrap();
18854        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18855        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18856        assert_eq!(
18857            inlines[2].text.as_deref(),
18858            Some("1. This is continuation text")
18859        );
18860    }
18861
18862    #[test]
18863    fn issue_455_paragraph_starts_with_ordered_marker_and_hardbreak() {
18864        // Coverage: first line IS a list marker AND paragraph has hardBreaks.
18865        // Exercises the escape_list_marker path on the first line of a
18866        // multi-line paragraph buf in the rendering code.
18867        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18868          {"type":"text","text":"1. Starting with a number"},
18869          {"type":"hardBreak"},
18870          {"type":"text","text":"continuation after break"}
18871        ]}]}"#;
18872        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18873        let md = adf_to_markdown(&doc).unwrap();
18874        // First line should be escaped so it's not parsed as ordered list
18875        assert!(
18876            md.contains(r"1\. Starting with a number"),
18877            "First line should have escaped list marker, got: {md:?}"
18878        );
18879        let rt = markdown_to_adf(&md).unwrap();
18880
18881        assert_eq!(rt.content.len(), 1);
18882        assert_eq!(rt.content[0].node_type, "paragraph");
18883        let inlines = rt.content[0].content.as_ref().unwrap();
18884        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18885        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18886        assert_eq!(
18887            inlines[0].text.as_deref(),
18888            Some("1. Starting with a number")
18889        );
18890        assert_eq!(inlines[2].text.as_deref(), Some("continuation after break"));
18891    }
18892
18893    #[test]
18894    fn ordered_marker_paragraph_in_table_cell_roundtrips() {
18895        // Issue #402: paragraph with "2. " text inside a tableCell must
18896        // not be re-parsed as an ordered list.
18897        let adf_json = r#"{"version":1,"type":"doc","content":[{
18898          "type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},
18899          "content":[{"type":"tableRow","content":[{
18900            "type":"tableCell","attrs":{"colspan":1,"rowspan":1},
18901            "content":[{"type":"paragraph","content":[
18902              {"type":"text","text":"2. Honouring existing commitments"}
18903            ]}]
18904          }]}]
18905        }]}"#;
18906        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18907        let md = adf_to_markdown(&doc).unwrap();
18908        let rt = markdown_to_adf(&md).unwrap();
18909
18910        let table = &rt.content[0];
18911        let cell = &table.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0];
18912        let para = &cell.content.as_ref().unwrap()[0];
18913        assert_eq!(para.node_type, "paragraph");
18914        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
18915        assert_eq!(text, "2. Honouring existing commitments");
18916    }
18917
18918    #[test]
18919    fn bullet_marker_paragraph_standalone_roundtrips() {
18920        // A top-level paragraph starting with "- " must round-trip as
18921        // a paragraph, not a bullet list.
18922        let adf_json = r#"{"version":1,"type":"doc","content":[
18923          {"type":"paragraph","content":[
18924            {"type":"text","text":"- not a list item"}
18925          ]}
18926        ]}"#;
18927        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18928        let md = adf_to_markdown(&doc).unwrap();
18929        assert!(
18930            md.contains(r"\- not a list item"),
18931            "Should escape the leading dash, got:\n{md}"
18932        );
18933        let rt = markdown_to_adf(&md).unwrap();
18934        assert_eq!(rt.content[0].node_type, "paragraph");
18935        let text = rt.content[0].content.as_ref().unwrap()[0]
18936            .text
18937            .as_deref()
18938            .unwrap();
18939        assert_eq!(text, "- not a list item");
18940    }
18941
18942    #[test]
18943    fn merge_adjacent_text_skips_non_text_nodes() {
18944        // Exercises the `else { i += 1 }` branch when adjacent nodes
18945        // are not both plain text.
18946        let mut nodes = vec![
18947            AdfNode::text("a"),
18948            AdfNode::hard_break(),
18949            AdfNode::text("b"),
18950        ];
18951        merge_adjacent_text(&mut nodes);
18952        assert_eq!(nodes.len(), 3);
18953    }
18954
18955    #[test]
18956    fn star_bullet_paragraph_roundtrips() {
18957        // Paragraph starting with "* " must round-trip without becoming
18958        // a bullet list.
18959        let adf_json = r#"{"version":1,"type":"doc","content":[
18960          {"type":"paragraph","content":[
18961            {"type":"text","text":"* starred"}
18962          ]}
18963        ]}"#;
18964        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18965        let md = adf_to_markdown(&doc).unwrap();
18966        let rt = markdown_to_adf(&md).unwrap();
18967        assert_eq!(rt.content[0].node_type, "paragraph");
18968        assert_eq!(
18969            rt.content[0].content.as_ref().unwrap()[0]
18970                .text
18971                .as_deref()
18972                .unwrap(),
18973            "* starred"
18974        );
18975    }
18976
18977    // ---- Issue #388 tests ----
18978
18979    #[test]
18980    fn issue_388_ordered_list_with_strong_hardbreak_roundtrips() {
18981        // Issue #388: orderedList with 2 listItems, each containing
18982        // strong-marked text + hardBreak + plain text.
18983        let adf_json = r#"{"version":1,"type":"doc","content":[
18984          {"type":"orderedList","attrs":{"order":1},"content":[
18985            {"type":"listItem","content":[
18986              {"type":"paragraph","content":[
18987                {"type":"text","text":"Bold heading","marks":[{"type":"strong"}]},
18988                {"type":"hardBreak"},
18989                {"type":"text","text":"Content after break"}
18990              ]}
18991            ]},
18992            {"type":"listItem","content":[
18993              {"type":"paragraph","content":[
18994                {"type":"text","text":"Second item","marks":[{"type":"strong"}]},
18995                {"type":"hardBreak"},
18996                {"type":"text","text":"More content"}
18997              ]}
18998            ]}
18999          ]}
19000        ]}"#;
19001        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19002        let md = adf_to_markdown(&doc).unwrap();
19003        let rt = markdown_to_adf(&md).unwrap();
19004
19005        // Must remain a single orderedList
19006        assert_eq!(
19007            rt.content.len(),
19008            1,
19009            "Should be 1 block (orderedList), got {}",
19010            rt.content.len()
19011        );
19012        assert_eq!(rt.content[0].node_type, "orderedList");
19013        let items = rt.content[0].content.as_ref().unwrap();
19014        assert_eq!(
19015            items.len(),
19016            2,
19017            "Should have 2 listItems, got {}",
19018            items.len()
19019        );
19020
19021        // First item: text(strong) + hardBreak + text
19022        let p1 = items[0].content.as_ref().unwrap()[0]
19023            .content
19024            .as_ref()
19025            .unwrap();
19026        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
19027        assert_eq!(types1, vec!["text", "hardBreak", "text"]);
19028        assert_eq!(p1[0].text.as_deref(), Some("Bold heading"));
19029        assert_eq!(p1[2].text.as_deref(), Some("Content after break"));
19030
19031        // Second item: text(strong) + hardBreak + text
19032        let p2 = items[1].content.as_ref().unwrap()[0]
19033            .content
19034            .as_ref()
19035            .unwrap();
19036        let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
19037        assert_eq!(types2, vec!["text", "hardBreak", "text"]);
19038        assert_eq!(p2[0].text.as_deref(), Some("Second item"));
19039        assert_eq!(p2[2].text.as_deref(), Some("More content"));
19040    }
19041
19042    #[test]
19043    fn issue_388_bullet_list_with_strong_hardbreak_roundtrips() {
19044        // Bullet list variant of issue #388.
19045        let adf_json = r#"{"version":1,"type":"doc","content":[
19046          {"type":"bulletList","content":[
19047            {"type":"listItem","content":[
19048              {"type":"paragraph","content":[
19049                {"type":"text","text":"First","marks":[{"type":"strong"}]},
19050                {"type":"hardBreak"},
19051                {"type":"text","text":"details"}
19052              ]}
19053            ]},
19054            {"type":"listItem","content":[
19055              {"type":"paragraph","content":[
19056                {"type":"text","text":"Second","marks":[{"type":"em"}]},
19057                {"type":"hardBreak"},
19058                {"type":"text","text":"more details"}
19059              ]}
19060            ]}
19061          ]}
19062        ]}"#;
19063        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19064        let md = adf_to_markdown(&doc).unwrap();
19065        let rt = markdown_to_adf(&md).unwrap();
19066
19067        assert_eq!(rt.content.len(), 1);
19068        assert_eq!(rt.content[0].node_type, "bulletList");
19069        let items = rt.content[0].content.as_ref().unwrap();
19070        assert_eq!(items.len(), 2);
19071
19072        let p1 = items[0].content.as_ref().unwrap()[0]
19073            .content
19074            .as_ref()
19075            .unwrap();
19076        assert_eq!(p1[0].text.as_deref(), Some("First"));
19077        assert_eq!(p1[2].text.as_deref(), Some("details"));
19078
19079        let p2 = items[1].content.as_ref().unwrap()[0]
19080            .content
19081            .as_ref()
19082            .unwrap();
19083        assert_eq!(p2[0].text.as_deref(), Some("Second"));
19084        assert_eq!(p2[2].text.as_deref(), Some("more details"));
19085    }
19086
19087    #[test]
19088    fn issue_388_ordered_list_hardbreak_jfm_indentation() {
19089        // Verify the JFM output has properly indented continuation lines.
19090        let adf_json = r#"{"version":1,"type":"doc","content":[
19091          {"type":"orderedList","attrs":{"order":1},"content":[
19092            {"type":"listItem","content":[
19093              {"type":"paragraph","content":[
19094                {"type":"text","text":"heading","marks":[{"type":"strong"}]},
19095                {"type":"hardBreak"},
19096                {"type":"text","text":"body"}
19097              ]}
19098            ]}
19099          ]}
19100        ]}"#;
19101        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19102        let md = adf_to_markdown(&doc).unwrap();
19103        assert!(
19104            md.contains("1. **heading**\\\n  body"),
19105            "Continuation should be indented, got:\n{md}"
19106        );
19107    }
19108
19109    #[test]
19110    fn issue_388_ordered_list_hardbreak_from_jfm() {
19111        // Direct JFM → ADF: ordered list with hardBreak continuation.
19112        let md = "1. **bold**\\\n  continued\n2. **also bold**\\\n  also continued\n";
19113        let doc = markdown_to_adf(md).unwrap();
19114
19115        assert_eq!(doc.content.len(), 1);
19116        assert_eq!(doc.content[0].node_type, "orderedList");
19117        let items = doc.content[0].content.as_ref().unwrap();
19118        assert_eq!(items.len(), 2);
19119
19120        let p1 = items[0].content.as_ref().unwrap()[0]
19121            .content
19122            .as_ref()
19123            .unwrap();
19124        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
19125        assert_eq!(types1, vec!["text", "hardBreak", "text"]);
19126        assert_eq!(p1[0].text.as_deref(), Some("bold"));
19127        assert_eq!(p1[2].text.as_deref(), Some("continued"));
19128
19129        let p2 = items[1].content.as_ref().unwrap()[0]
19130            .content
19131            .as_ref()
19132            .unwrap();
19133        let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
19134        assert_eq!(types2, vec!["text", "hardBreak", "text"]);
19135    }
19136
19137    #[test]
19138    fn issue_388_bullet_list_hardbreak_from_jfm() {
19139        // Direct JFM → ADF: bullet list with hardBreak continuation.
19140        let md = "- first\\\n  second\n- third\\\n  fourth\n";
19141        let doc = markdown_to_adf(md).unwrap();
19142
19143        assert_eq!(doc.content.len(), 1);
19144        assert_eq!(doc.content[0].node_type, "bulletList");
19145        let items = doc.content[0].content.as_ref().unwrap();
19146        assert_eq!(items.len(), 2);
19147
19148        for (i, expected) in [("first", "second"), ("third", "fourth")]
19149            .iter()
19150            .enumerate()
19151        {
19152            let p = items[i].content.as_ref().unwrap()[0]
19153                .content
19154                .as_ref()
19155                .unwrap();
19156            let types: Vec<&str> = p.iter().map(|n| n.node_type.as_str()).collect();
19157            assert_eq!(types, vec!["text", "hardBreak", "text"]);
19158            assert_eq!(p[0].text.as_deref(), Some(expected.0));
19159            assert_eq!(p[2].text.as_deref(), Some(expected.1));
19160        }
19161    }
19162
19163    #[test]
19164    fn issue_433_heading_hardbreak_roundtrips() {
19165        // Issue #433: hardBreak inside heading splits into heading + paragraph.
19166        let adf_json = r#"{"version":1,"type":"doc","content":[{
19167          "type":"heading",
19168          "attrs":{"level":1},
19169          "content":[
19170            {"type":"text","text":"Line one"},
19171            {"type":"hardBreak"},
19172            {"type":"text","text":"Line two"}
19173          ]
19174        }]}"#;
19175        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19176        let md = adf_to_markdown(&doc).unwrap();
19177        let rt = markdown_to_adf(&md).unwrap();
19178
19179        assert_eq!(
19180            rt.content.len(),
19181            1,
19182            "Should remain a single heading, got {} blocks",
19183            rt.content.len()
19184        );
19185        assert_eq!(rt.content[0].node_type, "heading");
19186        let inlines = rt.content[0].content.as_ref().unwrap();
19187        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19188        assert_eq!(
19189            types,
19190            vec!["text", "hardBreak", "text"],
19191            "hardBreak should be preserved, got: {types:?}"
19192        );
19193        assert_eq!(inlines[0].text.as_deref(), Some("Line one"));
19194        assert_eq!(inlines[2].text.as_deref(), Some("Line two"));
19195    }
19196
19197    #[test]
19198    fn issue_433_heading_hardbreak_jfm_indentation() {
19199        // Verify the JFM output has properly indented continuation lines.
19200        let adf_json = r#"{"version":1,"type":"doc","content":[{
19201          "type":"heading",
19202          "attrs":{"level":2},
19203          "content":[
19204            {"type":"text","text":"Title"},
19205            {"type":"hardBreak"},
19206            {"type":"text","text":"Subtitle"}
19207          ]
19208        }]}"#;
19209        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19210        let md = adf_to_markdown(&doc).unwrap();
19211        assert!(
19212            md.contains("## Title\\\n  Subtitle"),
19213            "Continuation should be indented, got:\n{md}"
19214        );
19215    }
19216
19217    #[test]
19218    fn issue_433_heading_hardbreak_from_jfm() {
19219        // Direct JFM → ADF: heading with hardBreak continuation.
19220        let md = "# First\\\n  Second\n";
19221        let doc = markdown_to_adf(md).unwrap();
19222
19223        assert_eq!(doc.content.len(), 1);
19224        assert_eq!(doc.content[0].node_type, "heading");
19225        let inlines = doc.content[0].content.as_ref().unwrap();
19226        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19227        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19228        assert_eq!(inlines[0].text.as_deref(), Some("First"));
19229        assert_eq!(inlines[2].text.as_deref(), Some("Second"));
19230    }
19231
19232    #[test]
19233    fn issue_433_heading_consecutive_hardbreaks_roundtrip() {
19234        // Consecutive hardBreaks in a heading.
19235        let adf_json = r#"{"version":1,"type":"doc","content":[{
19236          "type":"heading",
19237          "attrs":{"level":3},
19238          "content":[
19239            {"type":"text","text":"A"},
19240            {"type":"hardBreak"},
19241            {"type":"hardBreak"},
19242            {"type":"text","text":"B"}
19243          ]
19244        }]}"#;
19245        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19246        let md = adf_to_markdown(&doc).unwrap();
19247        let rt = markdown_to_adf(&md).unwrap();
19248
19249        assert_eq!(rt.content.len(), 1, "Should remain a single heading");
19250        assert_eq!(rt.content[0].node_type, "heading");
19251        let inlines = rt.content[0].content.as_ref().unwrap();
19252        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19253        assert_eq!(types, vec!["text", "hardBreak", "hardBreak", "text"]);
19254    }
19255
19256    #[test]
19257    fn issue_433_heading_with_strong_and_hardbreak_roundtrips() {
19258        // Heading with strong-marked text + hardBreak + plain text.
19259        let adf_json = r#"{"version":1,"type":"doc","content":[{
19260          "type":"heading",
19261          "attrs":{"level":1},
19262          "content":[
19263            {"type":"text","text":"Bold title","marks":[{"type":"strong"}]},
19264            {"type":"hardBreak"},
19265            {"type":"text","text":"plain continuation"}
19266          ]
19267        }]}"#;
19268        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19269        let md = adf_to_markdown(&doc).unwrap();
19270        let rt = markdown_to_adf(&md).unwrap();
19271
19272        assert_eq!(rt.content.len(), 1);
19273        assert_eq!(rt.content[0].node_type, "heading");
19274        let inlines = rt.content[0].content.as_ref().unwrap();
19275        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19276        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19277        assert_eq!(inlines[0].text.as_deref(), Some("Bold title"));
19278        assert_eq!(inlines[2].text.as_deref(), Some("plain continuation"));
19279    }
19280
19281    #[test]
19282    fn issue_433_heading_with_link_and_hardbreak_roundtrips() {
19283        // Real-world pattern: heading with link + hardBreak + text.
19284        let adf_json = r#"{"version":1,"type":"doc","content":[{
19285          "type":"heading",
19286          "attrs":{"level":1},
19287          "content":[
19288            {"type":"text","text":"Click here","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
19289            {"type":"hardBreak"},
19290            {"type":"text","text":"Subtitle text"}
19291          ]
19292        }]}"#;
19293        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19294        let md = adf_to_markdown(&doc).unwrap();
19295        let rt = markdown_to_adf(&md).unwrap();
19296
19297        assert_eq!(rt.content.len(), 1);
19298        assert_eq!(rt.content[0].node_type, "heading");
19299        let inlines = rt.content[0].content.as_ref().unwrap();
19300        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19301        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19302        assert_eq!(inlines[2].text.as_deref(), Some("Subtitle text"));
19303    }
19304
19305    #[test]
19306    fn has_trailing_hard_break_backslash() {
19307        assert!(has_trailing_hard_break("text\\"));
19308        assert!(has_trailing_hard_break("**bold**\\"));
19309    }
19310
19311    #[test]
19312    fn has_trailing_hard_break_trailing_spaces() {
19313        assert!(has_trailing_hard_break("text  "));
19314        assert!(has_trailing_hard_break("word   "));
19315    }
19316
19317    #[test]
19318    fn has_trailing_hard_break_false() {
19319        assert!(!has_trailing_hard_break("plain text"));
19320        assert!(!has_trailing_hard_break("text "));
19321        assert!(!has_trailing_hard_break(""));
19322    }
19323
19324    #[test]
19325    fn collect_hardbreak_continuations_collects_indented() {
19326        // A line ending with `\` followed by 2-space-indented continuation.
19327        // Only one line is collected because the result no longer ends with `\`.
19328        let input = "first\\\n  second\n  third\n";
19329        let mut parser = MarkdownParser::new(input);
19330        parser.advance(); // skip first line
19331        let mut text = "first\\".to_string();
19332        parser.collect_hardbreak_continuations(&mut text);
19333        assert_eq!(text, "first\\\nsecond");
19334    }
19335
19336    #[test]
19337    fn collect_hardbreak_continuations_stops_at_non_indented() {
19338        let input = "first\\\nnot indented\n";
19339        let mut parser = MarkdownParser::new(input);
19340        parser.advance();
19341        let mut text = "first\\".to_string();
19342        parser.collect_hardbreak_continuations(&mut text);
19343        // Should NOT collect the non-indented line
19344        assert_eq!(text, "first\\");
19345    }
19346
19347    #[test]
19348    fn collect_hardbreak_continuations_no_trailing_break() {
19349        // If the text doesn't end with a hardBreak marker, nothing is collected.
19350        let input = "plain\n  indented\n";
19351        let mut parser = MarkdownParser::new(input);
19352        parser.advance();
19353        let mut text = "plain".to_string();
19354        parser.collect_hardbreak_continuations(&mut text);
19355        assert_eq!(text, "plain");
19356    }
19357
19358    #[test]
19359    fn collect_hardbreak_continuations_chained() {
19360        // Multiple continuation lines chained via repeated hardBreaks.
19361        let input = "a\\\n  b\\\n  c\\\n  d\n";
19362        let mut parser = MarkdownParser::new(input);
19363        parser.advance();
19364        let mut text = "a\\".to_string();
19365        parser.collect_hardbreak_continuations(&mut text);
19366        assert_eq!(text, "a\\\nb\\\nc\\\nd");
19367    }
19368
19369    #[test]
19370    fn collect_hardbreak_continuations_stops_before_image_line() {
19371        // An indented continuation that starts with `![` (mediaSingle syntax)
19372        // must NOT be swallowed as a paragraph continuation (issue #490).
19373        let input = "text\\\n  ![](url){type=file id=x}\n";
19374        let mut parser = MarkdownParser::new(input);
19375        parser.advance(); // skip first line
19376        let mut text = "text\\".to_string();
19377        parser.collect_hardbreak_continuations(&mut text);
19378        // The image line should NOT have been consumed.
19379        assert_eq!(text, "text\\");
19380        // Parser should still be on the image line (not past it).
19381        assert!(!parser.at_end());
19382        assert!(parser.current_line().contains("![](url)"));
19383    }
19384
19385    #[test]
19386    fn is_block_level_continuation_marker_positive_cases() {
19387        // Each marker that forces `collect_hardbreak_continuations` to stop.
19388        assert!(is_block_level_continuation_marker("![](url)"));
19389        assert!(is_block_level_continuation_marker("```ruby"));
19390        assert!(is_block_level_continuation_marker(":::panel{type=info}"));
19391    }
19392
19393    #[test]
19394    fn is_block_level_continuation_marker_negative_cases() {
19395        // Plain continuation text must NOT look like a block-level marker.
19396        assert!(!is_block_level_continuation_marker("plain text"));
19397        assert!(!is_block_level_continuation_marker("- nested item"));
19398        assert!(!is_block_level_continuation_marker("continuation\\"));
19399        assert!(!is_block_level_continuation_marker(""));
19400        // Double-colon `::` is not a container directive.
19401        assert!(!is_block_level_continuation_marker("::partial"));
19402        // Single backticks are inline code, not a fence.
19403        assert!(!is_block_level_continuation_marker("`inline`"));
19404    }
19405
19406    #[test]
19407    fn collect_hardbreak_continuations_stops_before_code_fence() {
19408        // Issue #552: An indented continuation that opens a fenced code block
19409        // must NOT be swallowed as a paragraph continuation — it has to stay
19410        // available for `try_code_block` on the next parse iteration.
19411        let input = "text\\\n  ```ruby\n  Foo::Bar::Baz\n  ```\n";
19412        let mut parser = MarkdownParser::new(input);
19413        parser.advance();
19414        let mut text = "text\\".to_string();
19415        parser.collect_hardbreak_continuations(&mut text);
19416        assert_eq!(text, "text\\");
19417        assert!(!parser.at_end());
19418        assert!(parser.current_line().starts_with("  ```"));
19419    }
19420
19421    #[test]
19422    fn collect_hardbreak_continuations_stops_before_container_directive() {
19423        // Issue #552: An indented continuation that opens a `:::` container
19424        // directive (panel, expand, etc.) must also stay available for the
19425        // directive parser.
19426        let input = "text\\\n  :::panel{type=info}\n  body\n  :::\n";
19427        let mut parser = MarkdownParser::new(input);
19428        parser.advance();
19429        let mut text = "text\\".to_string();
19430        parser.collect_hardbreak_continuations(&mut text);
19431        assert_eq!(text, "text\\");
19432        assert!(!parser.at_end());
19433        assert!(parser.current_line().contains(":::panel"));
19434    }
19435
19436    #[test]
19437    fn collect_hardbreak_continuations_stops_before_indented_code_fence() {
19438        // Variant: extra leading whitespace on the code-fence line (so the
19439        // stripped tail is `  ```` rather than a bare ` ``` `) must still be
19440        // recognised by the `trim_start().starts_with("```")` check.
19441        let input = "text\\\n     ```text\n     :fire:\n     ```\n";
19442        let mut parser = MarkdownParser::new(input);
19443        parser.advance();
19444        let mut text = "text\\".to_string();
19445        parser.collect_hardbreak_continuations(&mut text);
19446        assert_eq!(text, "text\\");
19447        assert!(!parser.at_end());
19448        assert!(parser.current_line().contains("```text"));
19449    }
19450
19451    #[test]
19452    fn ordered_list_with_sub_content_after_hardbreak() {
19453        // Exercises the sub-content collection loop in parse_ordered_list
19454        // (lines 339-347) with a hardBreak item that also has a nested list.
19455        let adf_json = r#"{"version":1,"type":"doc","content":[
19456          {"type":"orderedList","attrs":{"order":1},"content":[
19457            {"type":"listItem","content":[
19458              {"type":"paragraph","content":[
19459                {"type":"text","text":"parent"},
19460                {"type":"hardBreak"},
19461                {"type":"text","text":"continued"}
19462              ]},
19463              {"type":"bulletList","content":[
19464                {"type":"listItem","content":[
19465                  {"type":"paragraph","content":[
19466                    {"type":"text","text":"child"}
19467                  ]}
19468                ]}
19469              ]}
19470            ]}
19471          ]}
19472        ]}"#;
19473        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19474        let md = adf_to_markdown(&doc).unwrap();
19475        let rt = markdown_to_adf(&md).unwrap();
19476
19477        assert_eq!(rt.content.len(), 1);
19478        assert_eq!(rt.content[0].node_type, "orderedList");
19479        let item_content = rt.content[0].content.as_ref().unwrap()[0]
19480            .content
19481            .as_ref()
19482            .unwrap();
19483        // Paragraph with hardBreak
19484        let p = item_content[0].content.as_ref().unwrap();
19485        let types: Vec<&str> = p.iter().map(|n| n.node_type.as_str()).collect();
19486        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19487        assert_eq!(p[0].text.as_deref(), Some("parent"));
19488        assert_eq!(p[2].text.as_deref(), Some("continued"));
19489        // Nested bullet list
19490        assert_eq!(item_content[1].node_type, "bulletList");
19491    }
19492
19493    #[test]
19494    fn render_list_item_content_no_content() {
19495        // A listItem with content: None should produce just a newline.
19496        let item = AdfNode {
19497            node_type: "listItem".to_string(),
19498            attrs: None,
19499            content: None,
19500            text: None,
19501            marks: None,
19502            local_id: None,
19503            parameters: None,
19504        };
19505        let mut output = String::new();
19506        let opts = RenderOptions::default();
19507        render_list_item_content(&item, &mut output, &opts);
19508        assert_eq!(output, "\n");
19509    }
19510
19511    #[test]
19512    fn render_list_item_content_empty_content() {
19513        // A listItem with content: Some(vec![]) should produce just a newline.
19514        let item = AdfNode::list_item(vec![]);
19515        let mut output = String::new();
19516        let opts = RenderOptions::default();
19517        render_list_item_content(&item, &mut output, &opts);
19518        assert_eq!(output, "\n");
19519    }
19520
19521    #[test]
19522    fn plus_bullet_paragraph_roundtrips() {
19523        // Paragraph starting with "+ " must round-trip without becoming
19524        // a bullet list.
19525        let adf_json = r#"{"version":1,"type":"doc","content":[
19526          {"type":"paragraph","content":[
19527            {"type":"text","text":"+ plus"}
19528          ]}
19529        ]}"#;
19530        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19531        let md = adf_to_markdown(&doc).unwrap();
19532        let rt = markdown_to_adf(&md).unwrap();
19533        assert_eq!(rt.content[0].node_type, "paragraph");
19534        assert_eq!(
19535            rt.content[0].content.as_ref().unwrap()[0]
19536                .text
19537                .as_deref()
19538                .unwrap(),
19539            "+ plus"
19540        );
19541    }
19542
19543    // ---- Issue #430 tests: mediaSingle inside listItem ----
19544
19545    #[test]
19546    fn issue_430_file_media_in_bullet_list_roundtrip() {
19547        // Issue #430: mediaSingle (type:file) as direct child of listItem
19548        // in a bulletList must survive round-trip.
19549        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19550          {"type":"listItem","content":[{
19551            "type":"mediaSingle",
19552            "attrs":{"layout":"center","width":1009,"widthType":"pixel"},
19553            "content":[{
19554              "type":"media",
19555              "attrs":{"collection":"contentId-123","height":576,"id":"00066e8e-554e-4d7e-af59-a0ef2888bdb6","type":"file","width":1009}
19556            }]
19557          }]}
19558        ]}]}"#;
19559        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19560        let md = adf_to_markdown(&doc).unwrap();
19561        let rt = markdown_to_adf(&md).unwrap();
19562
19563        let list = &rt.content[0];
19564        assert_eq!(list.node_type, "bulletList");
19565        let item = &list.content.as_ref().unwrap()[0];
19566        assert_eq!(item.node_type, "listItem");
19567        let ms = &item.content.as_ref().unwrap()[0];
19568        assert_eq!(ms.node_type, "mediaSingle");
19569        let ms_attrs = ms.attrs.as_ref().unwrap();
19570        assert_eq!(ms_attrs["layout"], "center");
19571        assert_eq!(ms_attrs["width"], 1009);
19572        assert_eq!(ms_attrs["widthType"], "pixel");
19573        let media = &ms.content.as_ref().unwrap()[0];
19574        assert_eq!(media.node_type, "media");
19575        let m_attrs = media.attrs.as_ref().unwrap();
19576        assert_eq!(m_attrs["type"], "file");
19577        assert_eq!(m_attrs["id"], "00066e8e-554e-4d7e-af59-a0ef2888bdb6");
19578        assert_eq!(m_attrs["collection"], "contentId-123");
19579        assert_eq!(m_attrs["height"], 576);
19580        assert_eq!(m_attrs["width"], 1009);
19581    }
19582
19583    #[test]
19584    fn issue_430_file_media_in_ordered_list_roundtrip() {
19585        // Same as above but inside an orderedList.
19586        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
19587          {"type":"listItem","content":[{
19588            "type":"mediaSingle",
19589            "attrs":{"layout":"center"},
19590            "content":[{
19591              "type":"media",
19592              "attrs":{"type":"file","id":"abc-123","collection":"contentId-456","height":100,"width":200}
19593            }]
19594          }]}
19595        ]}]}"#;
19596        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19597        let md = adf_to_markdown(&doc).unwrap();
19598        let rt = markdown_to_adf(&md).unwrap();
19599
19600        let list = &rt.content[0];
19601        assert_eq!(list.node_type, "orderedList");
19602        let item = &list.content.as_ref().unwrap()[0];
19603        assert_eq!(item.node_type, "listItem");
19604        let ms = &item.content.as_ref().unwrap()[0];
19605        assert_eq!(ms.node_type, "mediaSingle");
19606        let media = &ms.content.as_ref().unwrap()[0];
19607        assert_eq!(media.node_type, "media");
19608        let m_attrs = media.attrs.as_ref().unwrap();
19609        assert_eq!(m_attrs["type"], "file");
19610        assert_eq!(m_attrs["id"], "abc-123");
19611        assert_eq!(m_attrs["collection"], "contentId-456");
19612    }
19613
19614    #[test]
19615    fn issue_430_external_media_in_bullet_list_roundtrip() {
19616        // External image (type:external) inside a bullet list item.
19617        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19618          {"type":"listItem","content":[{
19619            "type":"mediaSingle",
19620            "attrs":{"layout":"center"},
19621            "content":[{
19622              "type":"media",
19623              "attrs":{"type":"external","url":"https://example.com/img.png","alt":"Photo"}
19624            }]
19625          }]}
19626        ]}]}"#;
19627        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19628        let md = adf_to_markdown(&doc).unwrap();
19629        let rt = markdown_to_adf(&md).unwrap();
19630
19631        let list = &rt.content[0];
19632        assert_eq!(list.node_type, "bulletList");
19633        let item = &list.content.as_ref().unwrap()[0];
19634        let ms = &item.content.as_ref().unwrap()[0];
19635        assert_eq!(ms.node_type, "mediaSingle");
19636        let media = &ms.content.as_ref().unwrap()[0];
19637        assert_eq!(media.node_type, "media");
19638        let m_attrs = media.attrs.as_ref().unwrap();
19639        assert_eq!(m_attrs["type"], "external");
19640        assert_eq!(m_attrs["url"], "https://example.com/img.png");
19641    }
19642
19643    #[test]
19644    fn issue_430_media_with_paragraph_siblings_in_list_item() {
19645        // listItem containing a paragraph followed by a mediaSingle.
19646        // Both children must survive round-trip.
19647        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19648          {"type":"listItem","content":[
19649            {"type":"paragraph","content":[{"type":"text","text":"Caption:"}]},
19650            {"type":"mediaSingle","attrs":{"layout":"center"},
19651             "content":[{"type":"media","attrs":{"type":"file","id":"img-001","collection":"col-1","height":50,"width":100}}]}
19652          ]}
19653        ]}]}"#;
19654        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19655        let md = adf_to_markdown(&doc).unwrap();
19656        let rt = markdown_to_adf(&md).unwrap();
19657
19658        let item = &rt.content[0].content.as_ref().unwrap()[0];
19659        let children = item.content.as_ref().unwrap();
19660        assert_eq!(children.len(), 2, "expected 2 children in listItem");
19661        assert_eq!(children[0].node_type, "paragraph");
19662        assert_eq!(children[1].node_type, "mediaSingle");
19663        let media = &children[1].content.as_ref().unwrap()[0];
19664        assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-001");
19665    }
19666
19667    #[test]
19668    fn issue_430_multiple_media_in_list_items() {
19669        // Multiple list items each containing mediaSingle.
19670        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19671          {"type":"listItem","content":[{
19672            "type":"mediaSingle","attrs":{"layout":"center"},
19673            "content":[{"type":"media","attrs":{"type":"file","id":"img-a","collection":"c1","height":10,"width":20}}]
19674          }]},
19675          {"type":"listItem","content":[{
19676            "type":"mediaSingle","attrs":{"layout":"center"},
19677            "content":[{"type":"media","attrs":{"type":"file","id":"img-b","collection":"c2","height":30,"width":40}}]
19678          }]}
19679        ]}]}"#;
19680        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19681        let md = adf_to_markdown(&doc).unwrap();
19682        let rt = markdown_to_adf(&md).unwrap();
19683
19684        let items = rt.content[0].content.as_ref().unwrap();
19685        assert_eq!(items.len(), 2);
19686        for (i, expected_id) in [("img-a", "c1"), ("img-b", "c2")].iter().enumerate() {
19687            let ms = &items[i].content.as_ref().unwrap()[0];
19688            assert_eq!(ms.node_type, "mediaSingle");
19689            let m_attrs = ms.content.as_ref().unwrap()[0].attrs.as_ref().unwrap();
19690            assert_eq!(m_attrs["id"], expected_id.0);
19691            assert_eq!(m_attrs["collection"], expected_id.1);
19692        }
19693    }
19694
19695    #[test]
19696    fn issue_430_jfm_to_adf_media_in_bullet_item() {
19697        // Parse JFM directly: image syntax on the first line of a bullet item
19698        // must produce mediaSingle, not a paragraph with corrupted text.
19699        let md = "- ![](){type=file id=test-id collection=col-1 height=100 width=200}\n";
19700        let doc = markdown_to_adf(md).unwrap();
19701
19702        let list = &doc.content[0];
19703        assert_eq!(list.node_type, "bulletList");
19704        let item = &list.content.as_ref().unwrap()[0];
19705        let ms = &item.content.as_ref().unwrap()[0];
19706        assert_eq!(
19707            ms.node_type, "mediaSingle",
19708            "expected mediaSingle, got {}",
19709            ms.node_type
19710        );
19711        let media = &ms.content.as_ref().unwrap()[0];
19712        assert_eq!(media.node_type, "media");
19713        let m_attrs = media.attrs.as_ref().unwrap();
19714        assert_eq!(m_attrs["type"], "file");
19715        assert_eq!(m_attrs["id"], "test-id");
19716    }
19717
19718    #[test]
19719    fn issue_430_jfm_to_adf_media_in_ordered_item() {
19720        // Parse JFM directly: image syntax on the first line of an ordered list item.
19721        let md = "1. ![alt text](https://example.com/photo.jpg)\n";
19722        let doc = markdown_to_adf(md).unwrap();
19723
19724        let list = &doc.content[0];
19725        assert_eq!(list.node_type, "orderedList");
19726        let item = &list.content.as_ref().unwrap()[0];
19727        let ms = &item.content.as_ref().unwrap()[0];
19728        assert_eq!(
19729            ms.node_type, "mediaSingle",
19730            "expected mediaSingle, got {}",
19731            ms.node_type
19732        );
19733    }
19734
19735    #[test]
19736    fn issue_430_media_then_paragraph_in_bullet_list_roundtrip() {
19737        // listItem with mediaSingle as first child followed by a paragraph.
19738        // Exercises the sub_lines non-empty path when first_node is mediaSingle.
19739        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19740          {"type":"listItem","content":[
19741            {"type":"mediaSingle","attrs":{"layout":"center"},
19742             "content":[{"type":"media","attrs":{"type":"file","id":"img-first","collection":"col-1","height":50,"width":100}}]},
19743            {"type":"paragraph","content":[{"type":"text","text":"Caption below"}]}
19744          ]}
19745        ]}]}"#;
19746        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19747        let md = adf_to_markdown(&doc).unwrap();
19748        let rt = markdown_to_adf(&md).unwrap();
19749
19750        let item = &rt.content[0].content.as_ref().unwrap()[0];
19751        let children = item.content.as_ref().unwrap();
19752        assert_eq!(children.len(), 2, "expected 2 children in listItem");
19753        assert_eq!(children[0].node_type, "mediaSingle");
19754        let media = &children[0].content.as_ref().unwrap()[0];
19755        assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-first");
19756        assert_eq!(children[1].node_type, "paragraph");
19757    }
19758
19759    #[test]
19760    fn issue_430_media_then_paragraph_in_ordered_list_roundtrip() {
19761        // Same as above but for ordered lists.
19762        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
19763          {"type":"listItem","content":[
19764            {"type":"mediaSingle","attrs":{"layout":"center"},
19765             "content":[{"type":"media","attrs":{"type":"file","id":"img-ord","collection":"col-2","height":60,"width":120}}]},
19766            {"type":"paragraph","content":[{"type":"text","text":"Description"}]}
19767          ]}
19768        ]}]}"#;
19769        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19770        let md = adf_to_markdown(&doc).unwrap();
19771        let rt = markdown_to_adf(&md).unwrap();
19772
19773        let item = &rt.content[0].content.as_ref().unwrap()[0];
19774        let children = item.content.as_ref().unwrap();
19775        assert_eq!(children.len(), 2, "expected 2 children in listItem");
19776        assert_eq!(children[0].node_type, "mediaSingle");
19777        assert_eq!(children[1].node_type, "paragraph");
19778    }
19779
19780    #[test]
19781    fn issue_430_external_media_with_width_type_roundtrip() {
19782        // External image with widthType attr must survive round-trip.
19783        let adf_json = r#"{"version":1,"type":"doc","content":[{
19784          "type":"mediaSingle",
19785          "attrs":{"layout":"wide","width":800,"widthType":"pixel"},
19786          "content":[{
19787            "type":"media",
19788            "attrs":{"type":"external","url":"https://example.com/photo.png","alt":"wide photo"}
19789          }]
19790        }]}"#;
19791        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19792        let md = adf_to_markdown(&doc).unwrap();
19793        assert!(
19794            md.contains("widthType=pixel"),
19795            "expected widthType=pixel in markdown, got: {md}"
19796        );
19797        let rt = markdown_to_adf(&md).unwrap();
19798        let ms = &rt.content[0];
19799        assert_eq!(ms.node_type, "mediaSingle");
19800        let ms_attrs = ms.attrs.as_ref().unwrap();
19801        assert_eq!(ms_attrs["widthType"], "pixel");
19802        assert_eq!(ms_attrs["width"], 800);
19803        assert_eq!(ms_attrs["layout"], "wide");
19804    }
19805
19806    // ── Issue #490: mediaSingle after hardBreak in listItem ─────
19807
19808    #[test]
19809    fn issue_490_paragraph_with_hardbreak_then_media_single_roundtrip() {
19810        // Reproducer from issue #490: paragraph with trailing hardBreak
19811        // followed by mediaSingle inside a listItem.
19812        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19813          {"type":"listItem","content":[
19814            {"type":"paragraph","content":[
19815              {"type":"text","text":"Item with image:"},
19816              {"type":"hardBreak"}
19817            ]},
19818            {"type":"mediaSingle","attrs":{"layout":"center","width":400,"widthType":"pixel"},
19819             "content":[{"type":"media","attrs":{
19820               "id":"aabbccdd-1234-5678-abcd-aabbccdd1234",
19821               "type":"file",
19822               "collection":"contentId-123456",
19823               "width":800,
19824               "height":600
19825             }}]}
19826          ]}
19827        ]}]}"#;
19828        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19829        let md = adf_to_markdown(&doc).unwrap();
19830        let rt = markdown_to_adf(&md).unwrap();
19831
19832        let item = &rt.content[0].content.as_ref().unwrap()[0];
19833        let children = item.content.as_ref().unwrap();
19834        assert_eq!(children.len(), 2, "expected 2 children in listItem");
19835        assert_eq!(children[0].node_type, "paragraph");
19836        assert_eq!(
19837            children[1].node_type, "mediaSingle",
19838            "expected mediaSingle, got {:?}",
19839            children[1].node_type
19840        );
19841        let media = &children[1].content.as_ref().unwrap()[0];
19842        let m_attrs = media.attrs.as_ref().unwrap();
19843        assert_eq!(m_attrs["id"], "aabbccdd-1234-5678-abcd-aabbccdd1234");
19844        assert_eq!(m_attrs["collection"], "contentId-123456");
19845        assert_eq!(m_attrs["height"], 600);
19846        assert_eq!(m_attrs["width"], 800);
19847    }
19848
19849    #[test]
19850    fn issue_490_paragraph_with_hardbreak_then_media_single_ordered_list() {
19851        // Same scenario but in an ordered list.
19852        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
19853          {"type":"listItem","content":[
19854            {"type":"paragraph","content":[
19855              {"type":"text","text":"Step with screenshot:"},
19856              {"type":"hardBreak"}
19857            ]},
19858            {"type":"mediaSingle","attrs":{"layout":"center"},
19859             "content":[{"type":"media","attrs":{
19860               "id":"ord-media-id","type":"file","collection":"col-ord","width":640,"height":480
19861             }}]}
19862          ]}
19863        ]}]}"#;
19864        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19865        let md = adf_to_markdown(&doc).unwrap();
19866        let rt = markdown_to_adf(&md).unwrap();
19867
19868        let item = &rt.content[0].content.as_ref().unwrap()[0];
19869        let children = item.content.as_ref().unwrap();
19870        assert_eq!(children.len(), 2, "expected 2 children in listItem");
19871        assert_eq!(children[0].node_type, "paragraph");
19872        assert_eq!(children[1].node_type, "mediaSingle");
19873        let media = &children[1].content.as_ref().unwrap()[0];
19874        assert_eq!(media.attrs.as_ref().unwrap()["id"], "ord-media-id");
19875    }
19876
19877    #[test]
19878    fn issue_490_hardbreak_continuation_does_not_swallow_media_line() {
19879        // Directly tests that collect_hardbreak_continuations stops before
19880        // an indented mediaSingle line.
19881        let md = "- Item with image:\\\n  ![](){type=file id=test-490 collection=col height=100 width=200}\n";
19882        let doc = markdown_to_adf(md).unwrap();
19883
19884        let item = &doc.content[0].content.as_ref().unwrap()[0];
19885        let children = item.content.as_ref().unwrap();
19886        assert_eq!(children.len(), 2, "expected 2 children in listItem");
19887        assert_eq!(children[0].node_type, "paragraph");
19888        assert_eq!(
19889            children[1].node_type, "mediaSingle",
19890            "expected mediaSingle as second child, got {:?}",
19891            children[1].node_type
19892        );
19893        let media = &children[1].content.as_ref().unwrap()[0];
19894        assert_eq!(media.attrs.as_ref().unwrap()["id"], "test-490");
19895    }
19896
19897    #[test]
19898    fn issue_490_hardbreak_continuation_still_works_for_text() {
19899        // Ensure regular hardBreak continuations still work after the fix.
19900        let md = "- first line\\\n  second line\n";
19901        let doc = markdown_to_adf(md).unwrap();
19902
19903        let item = &doc.content[0].content.as_ref().unwrap()[0];
19904        let children = item.content.as_ref().unwrap();
19905        assert_eq!(
19906            children.len(),
19907            1,
19908            "expected 1 child (paragraph) in listItem"
19909        );
19910        assert_eq!(children[0].node_type, "paragraph");
19911        let inlines = children[0].content.as_ref().unwrap();
19912        // Should contain: text("first line"), hardBreak, text("second line")
19913        assert_eq!(inlines.len(), 3);
19914        assert_eq!(inlines[0].node_type, "text");
19915        assert_eq!(inlines[1].node_type, "hardBreak");
19916        assert_eq!(inlines[2].node_type, "text");
19917    }
19918
19919    #[test]
19920    fn issue_490_external_media_after_hardbreak_roundtrip() {
19921        // External image (URL-based) after a paragraph with hardBreak.
19922        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19923          {"type":"listItem","content":[
19924            {"type":"paragraph","content":[
19925              {"type":"text","text":"See image:"},
19926              {"type":"hardBreak"}
19927            ]},
19928            {"type":"mediaSingle","attrs":{"layout":"center"},
19929             "content":[{"type":"media","attrs":{
19930               "type":"external","url":"https://example.com/photo.png","alt":"photo"
19931             }}]}
19932          ]}
19933        ]}]}"#;
19934        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19935        let md = adf_to_markdown(&doc).unwrap();
19936        let rt = markdown_to_adf(&md).unwrap();
19937
19938        let item = &rt.content[0].content.as_ref().unwrap()[0];
19939        let children = item.content.as_ref().unwrap();
19940        assert_eq!(children.len(), 2);
19941        assert_eq!(children[0].node_type, "paragraph");
19942        assert_eq!(children[1].node_type, "mediaSingle");
19943        let media = &children[1].content.as_ref().unwrap()[0];
19944        let m_attrs = media.attrs.as_ref().unwrap();
19945        assert_eq!(m_attrs["url"], "https://example.com/photo.png");
19946    }
19947
19948    #[test]
19949    fn issue_490_multiple_hardbreaks_then_media_single() {
19950        // Paragraph with multiple hardBreaks, then mediaSingle.
19951        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19952          {"type":"listItem","content":[
19953            {"type":"paragraph","content":[
19954              {"type":"text","text":"line one"},
19955              {"type":"hardBreak"},
19956              {"type":"text","text":"line two"},
19957              {"type":"hardBreak"}
19958            ]},
19959            {"type":"mediaSingle","attrs":{"layout":"center"},
19960             "content":[{"type":"media","attrs":{
19961               "type":"file","id":"multi-hb","collection":"col-m","width":320,"height":240
19962             }}]}
19963          ]}
19964        ]}]}"#;
19965        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19966        let md = adf_to_markdown(&doc).unwrap();
19967        let rt = markdown_to_adf(&md).unwrap();
19968
19969        let item = &rt.content[0].content.as_ref().unwrap()[0];
19970        let children = item.content.as_ref().unwrap();
19971        assert_eq!(children.len(), 2, "expected paragraph + mediaSingle");
19972        assert_eq!(children[0].node_type, "paragraph");
19973        assert_eq!(children[1].node_type, "mediaSingle");
19974        let media = &children[1].content.as_ref().unwrap()[0];
19975        assert_eq!(media.attrs.as_ref().unwrap()["id"], "multi-hb");
19976    }
19977
19978    // ── Issue #525: listItem localId dropped when content includes mediaSingle ──
19979
19980    #[test]
19981    fn issue_525_listitem_localid_with_mediasingle_roundtrip() {
19982        // Exact reproducer from issue #525: listItem with UUID localId whose
19983        // content includes mediaSingle + paragraph + nested bulletList.
19984        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"}]}]}]}]}]}]}"#;
19985        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19986        let md = adf_to_markdown(&doc).unwrap();
19987        let rt = markdown_to_adf(&md).unwrap();
19988
19989        let list = &rt.content[0];
19990        assert_eq!(list.node_type, "bulletList");
19991        let item = &list.content.as_ref().unwrap()[0];
19992        // The localId must be preserved on the listItem.
19993        let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
19994        assert_eq!(
19995            item_attrs["localId"], "aabbccdd-1234-5678-abcd-000000000001",
19996            "listItem localId must survive round-trip"
19997        );
19998        let children = item.content.as_ref().unwrap();
19999        assert_eq!(
20000            children.len(),
20001            3,
20002            "expected mediaSingle + paragraph + bulletList"
20003        );
20004        assert_eq!(children[0].node_type, "mediaSingle");
20005        assert_eq!(children[1].node_type, "paragraph");
20006        assert_eq!(children[2].node_type, "bulletList");
20007    }
20008
20009    #[test]
20010    fn issue_525_listitem_localid_with_mediasingle_only() {
20011        // Minimal case: listItem with localId whose sole child is mediaSingle.
20012        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20013          {"type":"listItem","attrs":{"localId":"li-media-only"},"content":[
20014            {"type":"mediaSingle","attrs":{"layout":"center"},
20015             "content":[{"type":"media","attrs":{"type":"file","id":"m-001","collection":"c1","height":50,"width":100}}]}
20016          ]}
20017        ]}]}"#;
20018        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20019        let md = adf_to_markdown(&doc).unwrap();
20020        let rt = markdown_to_adf(&md).unwrap();
20021
20022        let item = &rt.content[0].content.as_ref().unwrap()[0];
20023        let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20024        assert_eq!(
20025            item_attrs["localId"], "li-media-only",
20026            "listItem localId must survive when sole child is mediaSingle"
20027        );
20028        assert_eq!(item.content.as_ref().unwrap()[0].node_type, "mediaSingle");
20029    }
20030
20031    #[test]
20032    fn issue_525_listitem_localid_with_external_media() {
20033        // External image (URL-based) as first child with listItem localId.
20034        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20035          {"type":"listItem","attrs":{"localId":"li-ext-media"},"content":[
20036            {"type":"mediaSingle","attrs":{"layout":"center"},
20037             "content":[{"type":"media","attrs":{"type":"external","url":"https://example.com/img.png","alt":"photo"}}]}
20038          ]}
20039        ]}]}"#;
20040        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20041        let md = adf_to_markdown(&doc).unwrap();
20042        let rt = markdown_to_adf(&md).unwrap();
20043
20044        let item = &rt.content[0].content.as_ref().unwrap()[0];
20045        let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20046        assert_eq!(
20047            item_attrs["localId"], "li-ext-media",
20048            "listItem localId must survive with external mediaSingle"
20049        );
20050    }
20051
20052    #[test]
20053    fn issue_525_listitem_localid_with_mediasingle_in_ordered_list() {
20054        // Same bug in an ordered list.
20055        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
20056          {"type":"listItem","attrs":{"localId":"li-ord-media"},"content":[
20057            {"type":"mediaSingle","attrs":{"layout":"center","width":200,"widthType":"pixel"},
20058             "content":[{"type":"media","attrs":{"type":"file","id":"ord-m-001","collection":"col-ord","height":80,"width":160}}]},
20059            {"type":"paragraph","content":[{"type":"text","text":"ordered item text"}]}
20060          ]}
20061        ]}]}"#;
20062        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20063        let md = adf_to_markdown(&doc).unwrap();
20064        let rt = markdown_to_adf(&md).unwrap();
20065
20066        let item = &rt.content[0].content.as_ref().unwrap()[0];
20067        let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20068        assert_eq!(
20069            item_attrs["localId"], "li-ord-media",
20070            "listItem localId must survive in ordered list with mediaSingle"
20071        );
20072        let children = item.content.as_ref().unwrap();
20073        assert_eq!(children[0].node_type, "mediaSingle");
20074        assert_eq!(children[1].node_type, "paragraph");
20075    }
20076
20077    #[test]
20078    fn issue_525_jfm_localid_on_mediasingle_line_parses_correctly() {
20079        // Verify JFM→ADF: trailing {localId=...} on a mediaSingle line
20080        // is assigned to the listItem, not the media node.
20081        let md = "- ![](){type=file id=test-525 collection=col height=100 width=200 mediaWidth=100 widthType=pixel} {localId=li-jfm-525}\n";
20082        let doc = markdown_to_adf(md).unwrap();
20083
20084        let item = &doc.content[0].content.as_ref().unwrap()[0];
20085        let item_attrs = item
20086            .attrs
20087            .as_ref()
20088            .expect("listItem attrs must be present from JFM");
20089        assert_eq!(item_attrs["localId"], "li-jfm-525");
20090        assert_eq!(item.content.as_ref().unwrap()[0].node_type, "mediaSingle");
20091    }
20092
20093    #[test]
20094    fn issue_525_encoding_emits_localid_on_mediasingle_line() {
20095        // Verify the ADF→JFM encoding: localId appears on the same line
20096        // as the mediaSingle image syntax.
20097        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20098          {"type":"listItem","attrs":{"localId":"li-emit-check"},"content":[
20099            {"type":"mediaSingle","attrs":{"layout":"center"},
20100             "content":[{"type":"media","attrs":{"type":"file","id":"m-emit","collection":"c-emit","height":10,"width":20}}]}
20101          ]}
20102        ]}]}"#;
20103        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20104        let md = adf_to_markdown(&doc).unwrap();
20105        assert!(
20106            md.contains("{localId=li-emit-check}"),
20107            "expected localId in JFM output, got: {md}"
20108        );
20109        // The localId must be on the same line as the image
20110        for line in md.lines() {
20111            if line.contains("![") {
20112                assert!(
20113                    line.contains("localId=li-emit-check"),
20114                    "localId must be on the same line as the image: {line}"
20115                );
20116            }
20117        }
20118    }
20119
20120    // ── Placeholder node tests ────────────────────────────────────
20121
20122    #[test]
20123    fn adf_placeholder_to_markdown() {
20124        let doc = AdfDocument {
20125            version: 1,
20126            doc_type: "doc".to_string(),
20127            content: vec![AdfNode::paragraph(vec![AdfNode::placeholder(
20128                "Type something here",
20129            )])],
20130        };
20131        let md = adf_to_markdown(&doc).unwrap();
20132        assert!(
20133            md.contains(":placeholder[Type something here]"),
20134            "expected :placeholder directive, got: {md}"
20135        );
20136    }
20137
20138    #[test]
20139    fn markdown_placeholder_to_adf() {
20140        let doc = markdown_to_adf("Before :placeholder[Enter name] after").unwrap();
20141        let content = doc.content[0].content.as_ref().unwrap();
20142        assert_eq!(content[1].node_type, "placeholder");
20143        let attrs = content[1].attrs.as_ref().unwrap();
20144        assert_eq!(attrs["text"], "Enter name");
20145    }
20146
20147    #[test]
20148    fn placeholder_round_trip() {
20149        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"placeholder","attrs":{"text":"Type something here"}}]}]}"#;
20150        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20151        let md = adf_to_markdown(&doc).unwrap();
20152        let rt = markdown_to_adf(&md).unwrap();
20153        let content = rt.content[0].content.as_ref().unwrap();
20154        assert_eq!(content.len(), 1);
20155        assert_eq!(content[0].node_type, "placeholder");
20156        let attrs = content[0].attrs.as_ref().unwrap();
20157        assert_eq!(attrs["text"], "Type something here");
20158    }
20159
20160    #[test]
20161    fn placeholder_empty_text() {
20162        let doc = AdfDocument {
20163            version: 1,
20164            doc_type: "doc".to_string(),
20165            content: vec![AdfNode::paragraph(vec![AdfNode::placeholder("")])],
20166        };
20167        let md = adf_to_markdown(&doc).unwrap();
20168        assert!(
20169            md.contains(":placeholder[]"),
20170            "expected empty placeholder directive, got: {md}"
20171        );
20172        let rt = markdown_to_adf(&md).unwrap();
20173        let content = rt.content[0].content.as_ref().unwrap();
20174        assert_eq!(content[0].node_type, "placeholder");
20175        assert_eq!(content[0].attrs.as_ref().unwrap()["text"], "");
20176    }
20177
20178    #[test]
20179    fn placeholder_with_surrounding_text() {
20180        let md = "Click :placeholder[here] to continue\n";
20181        let doc = markdown_to_adf(md).unwrap();
20182        let content = doc.content[0].content.as_ref().unwrap();
20183        assert_eq!(content[0].text.as_deref(), Some("Click "));
20184        assert_eq!(content[1].node_type, "placeholder");
20185        assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "here");
20186        assert_eq!(content[2].text.as_deref(), Some(" to continue"));
20187    }
20188
20189    #[test]
20190    fn placeholder_missing_attrs() {
20191        // Placeholder node with no attrs should not panic
20192        let doc = AdfDocument {
20193            version: 1,
20194            doc_type: "doc".to_string(),
20195            content: vec![AdfNode::paragraph(vec![AdfNode {
20196                node_type: "placeholder".to_string(),
20197                attrs: None,
20198                content: None,
20199                text: None,
20200                marks: None,
20201                local_id: None,
20202                parameters: None,
20203            }])],
20204        };
20205        let md = adf_to_markdown(&doc).unwrap();
20206        // With no attrs, nothing is emitted for the placeholder
20207        assert!(!md.contains("placeholder"));
20208    }
20209
20210    // Issue #446: mention in table+list loses id and misplaces localId
20211    #[test]
20212    fn mention_in_table_bullet_list_preserves_id_and_local_id() {
20213        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":" "}]}]}]}]}]}]}]}"#;
20214        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20215        let md = adf_to_markdown(&doc).unwrap();
20216        let rt = markdown_to_adf(&md).unwrap();
20217
20218        // Navigate: doc → table → tableRow → tableCell → bulletList → listItem → paragraph
20219        let cell = &rt.content[0].content.as_ref().unwrap()[0]
20220            .content
20221            .as_ref()
20222            .unwrap()[0];
20223        let list = &cell.content.as_ref().unwrap()[0];
20224        let list_item = &list.content.as_ref().unwrap()[0];
20225
20226        // listItem must NOT have a localId attribute
20227        assert!(
20228            list_item
20229                .attrs
20230                .as_ref()
20231                .and_then(|a| a.get("localId"))
20232                .is_none(),
20233            "localId should stay on the mention, not the listItem"
20234        );
20235
20236        let para = &list_item.content.as_ref().unwrap()[0];
20237        let inlines = para.content.as_ref().unwrap();
20238
20239        // Should have: text("prefix text "), mention, text(" ")
20240        assert_eq!(inlines.len(), 3, "expected 3 inline nodes, got {inlines:?}");
20241
20242        assert_eq!(inlines[0].node_type, "text");
20243        assert_eq!(inlines[0].text.as_deref(), Some("prefix text "));
20244
20245        assert_eq!(inlines[1].node_type, "mention");
20246        let mention_attrs = inlines[1].attrs.as_ref().unwrap();
20247        assert_eq!(
20248            mention_attrs["id"], "aabbccdd11223344aabbccdd",
20249            "mention id must be preserved"
20250        );
20251        assert_eq!(
20252            mention_attrs["localId"], "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
20253            "mention localId must be preserved"
20254        );
20255        assert_eq!(mention_attrs["text"], "@Alice Example");
20256
20257        assert_eq!(inlines[2].node_type, "text");
20258        assert_eq!(inlines[2].text.as_deref(), Some(" "));
20259    }
20260
20261    #[test]
20262    fn mention_in_bullet_list_preserves_id_and_local_id() {
20263        // Same bug outside of a table context
20264        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":" "}]}]}]}]}"#;
20265        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20266        let md = adf_to_markdown(&doc).unwrap();
20267        let rt = markdown_to_adf(&md).unwrap();
20268
20269        let list_item = &rt.content[0].content.as_ref().unwrap()[0];
20270        assert!(
20271            list_item
20272                .attrs
20273                .as_ref()
20274                .and_then(|a| a.get("localId"))
20275                .is_none(),
20276            "localId should stay on the mention, not the listItem"
20277        );
20278
20279        let para = &list_item.content.as_ref().unwrap()[0];
20280        let inlines = para.content.as_ref().unwrap();
20281        assert_eq!(inlines[0].node_type, "mention");
20282        let mention_attrs = inlines[0].attrs.as_ref().unwrap();
20283        assert_eq!(mention_attrs["id"], "user123");
20284        assert_eq!(
20285            mention_attrs["localId"],
20286            "11111111-2222-3333-4444-555555555555"
20287        );
20288    }
20289
20290    #[test]
20291    fn mention_in_ordered_list_preserves_id_and_local_id() {
20292        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"}}]}]}]}]}"#;
20293        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20294        let md = adf_to_markdown(&doc).unwrap();
20295        let rt = markdown_to_adf(&md).unwrap();
20296
20297        let list_item = &rt.content[0].content.as_ref().unwrap()[0];
20298        assert!(
20299            list_item
20300                .attrs
20301                .as_ref()
20302                .and_then(|a| a.get("localId"))
20303                .is_none(),
20304            "localId should stay on the mention, not the listItem"
20305        );
20306
20307        let para = &list_item.content.as_ref().unwrap()[0];
20308        let inlines = para.content.as_ref().unwrap();
20309        assert_eq!(inlines[1].node_type, "mention");
20310        let mention_attrs = inlines[1].attrs.as_ref().unwrap();
20311        assert_eq!(mention_attrs["id"], "xyz");
20312        assert_eq!(mention_attrs["localId"], "aaaa-bbbb");
20313    }
20314
20315    #[test]
20316    fn list_item_own_local_id_with_mention_both_preserved() {
20317        // When a listItem has its own localId AND contains a mention with localId,
20318        // both should be preserved independently.
20319        let md = "- hello :mention[@Eve]{id=e1 localId=mention-lid} {localId=item-lid}\n";
20320        let doc = markdown_to_adf(md).unwrap();
20321        let list_item = &doc.content[0].content.as_ref().unwrap()[0];
20322
20323        // listItem should have its own localId
20324        let item_attrs = list_item.attrs.as_ref().unwrap();
20325        assert_eq!(item_attrs["localId"], "item-lid");
20326
20327        // mention should have its own localId
20328        let para = &list_item.content.as_ref().unwrap()[0];
20329        let inlines = para.content.as_ref().unwrap();
20330        let mention = inlines.iter().find(|n| n.node_type == "mention").unwrap();
20331        let mention_attrs = mention.attrs.as_ref().unwrap();
20332        assert_eq!(mention_attrs["id"], "e1");
20333        assert_eq!(mention_attrs["localId"], "mention-lid");
20334    }
20335
20336    #[test]
20337    fn extract_trailing_local_id_ignores_directive_attrs() {
20338        // Directly test the helper: a line ending with a directive's {…}
20339        // should NOT be treated as a trailing localId.
20340        let line = "text :mention[@X]{id=abc localId=uuid}";
20341        let (text, lid, plid) = extract_trailing_local_id(line);
20342        assert_eq!(text, line, "text should be unchanged");
20343        assert!(
20344            lid.is_none(),
20345            "should not extract localId from directive attrs"
20346        );
20347        assert!(plid.is_none());
20348    }
20349
20350    #[test]
20351    fn extract_trailing_local_id_matches_standalone_block() {
20352        // A standalone trailing {localId=…} separated by whitespace should still work.
20353        let line = "some text {localId=abc-123}";
20354        let (text, lid, plid) = extract_trailing_local_id(line);
20355        assert_eq!(text, "some text");
20356        assert_eq!(lid.as_deref(), Some("abc-123"));
20357        assert!(plid.is_none());
20358    }
20359
20360    // --- Issue #454: literal newline in text node inside listItem paragraph ---
20361
20362    #[test]
20363    fn newline_in_text_node_roundtrips_in_bullet_list() {
20364        // A text node containing a literal \n inside a bullet list item
20365        // must round-trip as a single text node with the embedded newline
20366        // preserved, not split into multiple paragraphs or hardBreak nodes.
20367        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"}]}]}]}]}"#;
20368        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20369        let md = adf_to_markdown(&doc).unwrap();
20370        let rt = markdown_to_adf(&md).unwrap();
20371
20372        // Should still be a single bulletList with one listItem
20373        assert_eq!(rt.content.len(), 1);
20374        let list = &rt.content[0];
20375        assert_eq!(list.node_type, "bulletList");
20376        let items = list.content.as_ref().unwrap();
20377        assert_eq!(items.len(), 1);
20378
20379        // The listItem should have exactly one paragraph child
20380        let item_content = items[0].content.as_ref().unwrap();
20381        assert_eq!(
20382            item_content.len(),
20383            1,
20384            "listItem should have exactly one paragraph"
20385        );
20386        assert_eq!(item_content[0].node_type, "paragraph");
20387
20388        // The embedded newline must survive as a single text node
20389        let inlines = item_content[0].content.as_ref().unwrap();
20390        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20391        assert_eq!(
20392            types,
20393            vec!["text", "hardBreak", "text"],
20394            "embedded newline should stay in a single text node, not produce extra hardBreaks"
20395        );
20396        assert_eq!(
20397            inlines[2].text.as_deref(),
20398            Some("first command\nsecond command")
20399        );
20400    }
20401
20402    #[test]
20403    fn newline_in_text_node_roundtrips_in_ordered_list() {
20404        // Same as above but in an ordered list.
20405        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"}]}]}]}]}"#;
20406        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20407        let md = adf_to_markdown(&doc).unwrap();
20408        let rt = markdown_to_adf(&md).unwrap();
20409
20410        let list = &rt.content[0];
20411        assert_eq!(list.node_type, "orderedList");
20412        let items = list.content.as_ref().unwrap();
20413        assert_eq!(items.len(), 1);
20414
20415        let item_content = items[0].content.as_ref().unwrap();
20416        assert_eq!(item_content.len(), 1);
20417        assert_eq!(item_content[0].node_type, "paragraph");
20418
20419        let inlines = item_content[0].content.as_ref().unwrap();
20420        assert_eq!(inlines.len(), 1);
20421        assert_eq!(inlines[0].node_type, "text");
20422        assert_eq!(inlines[0].text.as_deref(), Some("first\nsecond"));
20423    }
20424
20425    #[test]
20426    fn newline_in_text_node_roundtrips_in_paragraph() {
20427        // A text node with \n in a top-level paragraph should render as
20428        // escaped \n and round-trip back to a single text node.
20429        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello\nworld"}]}]}"#;
20430        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20431        let md = adf_to_markdown(&doc).unwrap();
20432        assert!(
20433            md.contains("hello\\nworld"),
20434            "newline in text node should render as escaped \\n: {md:?}"
20435        );
20436
20437        let rt = markdown_to_adf(&md).unwrap();
20438        let inlines = rt.content[0].content.as_ref().unwrap();
20439        assert_eq!(inlines.len(), 1);
20440        assert_eq!(inlines[0].text.as_deref(), Some("hello\nworld"));
20441    }
20442
20443    #[test]
20444    fn multiple_newlines_in_text_node_roundtrip() {
20445        // Multiple \n characters should each round-trip within the same text node.
20446        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a\nb\nc"}]}]}]}]}"#;
20447        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20448        let md = adf_to_markdown(&doc).unwrap();
20449        let rt = markdown_to_adf(&md).unwrap();
20450
20451        let item_content = rt.content[0].content.as_ref().unwrap()[0]
20452            .content
20453            .as_ref()
20454            .unwrap();
20455        assert_eq!(item_content.len(), 1);
20456
20457        let inlines = item_content[0].content.as_ref().unwrap();
20458        assert_eq!(inlines.len(), 1);
20459        assert_eq!(inlines[0].text.as_deref(), Some("a\nb\nc"));
20460    }
20461
20462    #[test]
20463    fn newline_in_marked_text_node_roundtrips() {
20464        // A bold text node with \n should round-trip preserving both
20465        // the marks and the embedded newline.
20466        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"bold\ntext","marks":[{"type":"strong"}]}]}]}"#;
20467        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20468        let md = adf_to_markdown(&doc).unwrap();
20469        assert!(
20470            md.contains("**bold\\ntext**"),
20471            "bold text with embedded newline should stay in one marked run: {md:?}"
20472        );
20473
20474        let rt = markdown_to_adf(&md).unwrap();
20475        let inlines = rt.content[0].content.as_ref().unwrap();
20476        assert_eq!(inlines.len(), 1);
20477        assert_eq!(inlines[0].text.as_deref(), Some("bold\ntext"));
20478        assert!(inlines[0]
20479            .marks
20480            .as_ref()
20481            .unwrap()
20482            .iter()
20483            .any(|m| m.mark_type == "strong"));
20484    }
20485
20486    #[test]
20487    fn trailing_newline_in_text_node_roundtrips() {
20488        // A text node ending with \n should round-trip preserving the
20489        // trailing newline.
20490        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"trailing\n"}]}]}"#;
20491        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20492        let md = adf_to_markdown(&doc).unwrap();
20493        assert!(
20494            md.contains("trailing\\n"),
20495            "trailing newline should be escaped: {md:?}"
20496        );
20497
20498        let rt = markdown_to_adf(&md).unwrap();
20499        let inlines = rt.content[0].content.as_ref().unwrap();
20500        assert_eq!(inlines.len(), 1);
20501        assert_eq!(inlines[0].text.as_deref(), Some("trailing\n"));
20502    }
20503
20504    #[test]
20505    fn hardbreak_and_embedded_newline_are_distinct() {
20506        // A hardBreak node and an embedded \n in a text node must not be
20507        // conflated — each must round-trip to its original form.
20508        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"}]}]}"#;
20509        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20510        let md = adf_to_markdown(&doc).unwrap();
20511        let rt = markdown_to_adf(&md).unwrap();
20512
20513        let inlines = rt.content[0].content.as_ref().unwrap();
20514        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20515        assert_eq!(
20516            types,
20517            vec!["text", "hardBreak", "text", "hardBreak", "text"]
20518        );
20519        assert_eq!(inlines[0].text.as_deref(), Some("before"));
20520        assert_eq!(inlines[2].text.as_deref(), Some("mid\ndle"));
20521        assert_eq!(inlines[4].text.as_deref(), Some("after"));
20522    }
20523
20524    // ---- Issue #472 tests ----
20525
20526    #[test]
20527    fn issue_472_bullet_list_trailing_hardbreak_roundtrips() {
20528        // Issue #472: trailing hardBreak at end of listItem paragraph must
20529        // not split the parent bulletList on round-trip.
20530        let adf_json = r#"{"version":1,"type":"doc","content":[
20531          {"type":"bulletList","content":[
20532            {"type":"listItem","content":[
20533              {"type":"paragraph","content":[
20534                {"type":"text","text":"First item"},
20535                {"type":"hardBreak"}
20536              ]}
20537            ]},
20538            {"type":"listItem","content":[
20539              {"type":"paragraph","content":[
20540                {"type":"text","text":"Second item"}
20541              ]}
20542            ]}
20543          ]}
20544        ]}"#;
20545        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20546        let md = adf_to_markdown(&doc).unwrap();
20547        let rt = markdown_to_adf(&md).unwrap();
20548
20549        // Must remain a single bulletList
20550        assert_eq!(
20551            rt.content.len(),
20552            1,
20553            "Should be 1 block (bulletList), got {}",
20554            rt.content.len()
20555        );
20556        assert_eq!(rt.content[0].node_type, "bulletList");
20557        let items = rt.content[0].content.as_ref().unwrap();
20558        assert_eq!(
20559            items.len(),
20560            2,
20561            "Should have 2 listItems, got {}",
20562            items.len()
20563        );
20564
20565        // First item: text + hardBreak (trailing)
20566        let p1 = items[0].content.as_ref().unwrap()[0]
20567            .content
20568            .as_ref()
20569            .unwrap();
20570        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
20571        assert_eq!(types1, vec!["text", "hardBreak"]);
20572        assert_eq!(p1[0].text.as_deref(), Some("First item"));
20573
20574        // Second item: text only
20575        let p2 = items[1].content.as_ref().unwrap()[0]
20576            .content
20577            .as_ref()
20578            .unwrap();
20579        assert_eq!(p2[0].text.as_deref(), Some("Second item"));
20580    }
20581
20582    #[test]
20583    fn issue_472_ordered_list_trailing_hardbreak_roundtrips() {
20584        // Ordered list variant of issue #472.
20585        let adf_json = r#"{"version":1,"type":"doc","content":[
20586          {"type":"orderedList","attrs":{"order":1},"content":[
20587            {"type":"listItem","content":[
20588              {"type":"paragraph","content":[
20589                {"type":"text","text":"Alpha"},
20590                {"type":"hardBreak"}
20591              ]}
20592            ]},
20593            {"type":"listItem","content":[
20594              {"type":"paragraph","content":[
20595                {"type":"text","text":"Beta"}
20596              ]}
20597            ]}
20598          ]}
20599        ]}"#;
20600        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20601        let md = adf_to_markdown(&doc).unwrap();
20602        let rt = markdown_to_adf(&md).unwrap();
20603
20604        assert_eq!(rt.content.len(), 1);
20605        assert_eq!(rt.content[0].node_type, "orderedList");
20606        let items = rt.content[0].content.as_ref().unwrap();
20607        assert_eq!(items.len(), 2);
20608
20609        let p1 = items[0].content.as_ref().unwrap()[0]
20610            .content
20611            .as_ref()
20612            .unwrap();
20613        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
20614        assert_eq!(types1, vec!["text", "hardBreak"]);
20615        assert_eq!(p1[0].text.as_deref(), Some("Alpha"));
20616    }
20617
20618    #[test]
20619    fn issue_472_trailing_hardbreak_jfm_no_blank_line() {
20620        // The rendered JFM must not contain a blank line after the
20621        // trailing hardBreak — that would split the list.
20622        let adf_json = r#"{"version":1,"type":"doc","content":[
20623          {"type":"bulletList","content":[
20624            {"type":"listItem","content":[
20625              {"type":"paragraph","content":[
20626                {"type":"text","text":"Hello"},
20627                {"type":"hardBreak"}
20628              ]}
20629            ]},
20630            {"type":"listItem","content":[
20631              {"type":"paragraph","content":[
20632                {"type":"text","text":"World"}
20633              ]}
20634            ]}
20635          ]}
20636        ]}"#;
20637        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20638        let md = adf_to_markdown(&doc).unwrap();
20639
20640        // Should produce "- Hello\\n- World\n" (no blank line between items).
20641        assert_eq!(md, "- Hello\\\n- World\n");
20642    }
20643
20644    #[test]
20645    fn issue_472_multiple_trailing_hardbreaks_roundtrip() {
20646        // Multiple trailing hardBreaks at the end of a listItem paragraph.
20647        let adf_json = r#"{"version":1,"type":"doc","content":[
20648          {"type":"bulletList","content":[
20649            {"type":"listItem","content":[
20650              {"type":"paragraph","content":[
20651                {"type":"text","text":"Item"},
20652                {"type":"hardBreak"},
20653                {"type":"hardBreak"}
20654              ]}
20655            ]},
20656            {"type":"listItem","content":[
20657              {"type":"paragraph","content":[
20658                {"type":"text","text":"Next"}
20659              ]}
20660            ]}
20661          ]}
20662        ]}"#;
20663        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20664        let md = adf_to_markdown(&doc).unwrap();
20665        let rt = markdown_to_adf(&md).unwrap();
20666
20667        // Must remain a single bulletList
20668        assert_eq!(rt.content.len(), 1);
20669        assert_eq!(rt.content[0].node_type, "bulletList");
20670        let items = rt.content[0].content.as_ref().unwrap();
20671        assert_eq!(items.len(), 2);
20672
20673        // First item should preserve both hardBreaks
20674        let p1 = items[0].content.as_ref().unwrap()[0]
20675            .content
20676            .as_ref()
20677            .unwrap();
20678        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
20679        assert_eq!(types1, vec!["text", "hardBreak", "hardBreak"]);
20680    }
20681
20682    #[test]
20683    fn issue_472_hardbreak_mid_and_trailing_roundtrip() {
20684        // A hardBreak in the middle AND at the end of a listItem paragraph.
20685        let adf_json = r#"{"version":1,"type":"doc","content":[
20686          {"type":"bulletList","content":[
20687            {"type":"listItem","content":[
20688              {"type":"paragraph","content":[
20689                {"type":"text","text":"Line one"},
20690                {"type":"hardBreak"},
20691                {"type":"text","text":"Line two"},
20692                {"type":"hardBreak"}
20693              ]}
20694            ]},
20695            {"type":"listItem","content":[
20696              {"type":"paragraph","content":[
20697                {"type":"text","text":"Other item"}
20698              ]}
20699            ]}
20700          ]}
20701        ]}"#;
20702        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20703        let md = adf_to_markdown(&doc).unwrap();
20704        let rt = markdown_to_adf(&md).unwrap();
20705
20706        assert_eq!(rt.content.len(), 1);
20707        assert_eq!(rt.content[0].node_type, "bulletList");
20708        let items = rt.content[0].content.as_ref().unwrap();
20709        assert_eq!(items.len(), 2);
20710
20711        let p1 = items[0].content.as_ref().unwrap()[0]
20712            .content
20713            .as_ref()
20714            .unwrap();
20715        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
20716        assert_eq!(types1, vec!["text", "hardBreak", "text", "hardBreak"]);
20717        assert_eq!(p1[0].text.as_deref(), Some("Line one"));
20718        assert_eq!(p1[2].text.as_deref(), Some("Line two"));
20719    }
20720
20721    #[test]
20722    fn issue_472_only_hardbreak_in_listitem_paragraph() {
20723        // Edge case: paragraph contains only a hardBreak, no text.
20724        let adf_json = r#"{"version":1,"type":"doc","content":[
20725          {"type":"bulletList","content":[
20726            {"type":"listItem","content":[
20727              {"type":"paragraph","content":[
20728                {"type":"hardBreak"}
20729              ]}
20730            ]},
20731            {"type":"listItem","content":[
20732              {"type":"paragraph","content":[
20733                {"type":"text","text":"After"}
20734              ]}
20735            ]}
20736          ]}
20737        ]}"#;
20738        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20739        let md = adf_to_markdown(&doc).unwrap();
20740        let rt = markdown_to_adf(&md).unwrap();
20741
20742        // Must remain a single bulletList with 2 items
20743        assert_eq!(rt.content.len(), 1);
20744        assert_eq!(rt.content[0].node_type, "bulletList");
20745        let items = rt.content[0].content.as_ref().unwrap();
20746        assert_eq!(items.len(), 2);
20747    }
20748
20749    #[test]
20750    fn issue_472_three_items_middle_has_trailing_hardbreak() {
20751        // Three-item list where only the middle item has a trailing hardBreak.
20752        let adf_json = r#"{"version":1,"type":"doc","content":[
20753          {"type":"bulletList","content":[
20754            {"type":"listItem","content":[
20755              {"type":"paragraph","content":[
20756                {"type":"text","text":"First"}
20757              ]}
20758            ]},
20759            {"type":"listItem","content":[
20760              {"type":"paragraph","content":[
20761                {"type":"text","text":"Second"},
20762                {"type":"hardBreak"}
20763              ]}
20764            ]},
20765            {"type":"listItem","content":[
20766              {"type":"paragraph","content":[
20767                {"type":"text","text":"Third"}
20768              ]}
20769            ]}
20770          ]}
20771        ]}"#;
20772        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20773        let md = adf_to_markdown(&doc).unwrap();
20774        let rt = markdown_to_adf(&md).unwrap();
20775
20776        assert_eq!(rt.content.len(), 1);
20777        assert_eq!(rt.content[0].node_type, "bulletList");
20778        let items = rt.content[0].content.as_ref().unwrap();
20779        assert_eq!(items.len(), 3);
20780        assert_eq!(
20781            items[0].content.as_ref().unwrap()[0]
20782                .content
20783                .as_ref()
20784                .unwrap()[0]
20785                .text
20786                .as_deref(),
20787            Some("First")
20788        );
20789        assert_eq!(
20790            items[2].content.as_ref().unwrap()[0]
20791                .content
20792                .as_ref()
20793                .unwrap()[0]
20794                .text
20795                .as_deref(),
20796            Some("Third")
20797        );
20798    }
20799
20800    // ── Issue #494: trailing space-only text node after hardBreak ────
20801
20802    #[test]
20803    fn issue_494_space_after_hardbreak_roundtrip() {
20804        // The original reproducer from issue #494: a single space text
20805        // node following a hardBreak is silently dropped on round-trip.
20806        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
20807          {"type":"text","text":"Some text"},
20808          {"type":"hardBreak"},
20809          {"type":"text","text":" "}
20810        ]}]}"#;
20811        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20812        let md = adf_to_markdown(&doc).unwrap();
20813        let rt = markdown_to_adf(&md).unwrap();
20814        let inlines = rt.content[0].content.as_ref().unwrap();
20815        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20816        assert_eq!(
20817            types,
20818            vec!["text", "hardBreak", "text"],
20819            "space-only text node after hardBreak should survive round-trip"
20820        );
20821        assert_eq!(inlines[2].text.as_deref(), Some(" "));
20822    }
20823
20824    #[test]
20825    fn issue_494_multiple_spaces_after_hardbreak_roundtrip() {
20826        // Multiple spaces after hardBreak should also survive.
20827        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
20828          {"type":"text","text":"Hello"},
20829          {"type":"hardBreak"},
20830          {"type":"text","text":"   "}
20831        ]}]}"#;
20832        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20833        let md = adf_to_markdown(&doc).unwrap();
20834        let rt = markdown_to_adf(&md).unwrap();
20835        let inlines = rt.content[0].content.as_ref().unwrap();
20836        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20837        assert_eq!(
20838            types,
20839            vec!["text", "hardBreak", "text"],
20840            "multi-space text node after hardBreak should survive round-trip"
20841        );
20842        assert_eq!(inlines[2].text.as_deref(), Some("   "));
20843    }
20844
20845    #[test]
20846    fn issue_494_space_then_text_after_hardbreak_roundtrip() {
20847        // Space followed by real text after hardBreak — the space should
20848        // be preserved as part of the text node.
20849        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
20850          {"type":"text","text":"Before"},
20851          {"type":"hardBreak"},
20852          {"type":"text","text":" After"}
20853        ]}]}"#;
20854        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20855        let md = adf_to_markdown(&doc).unwrap();
20856        let rt = markdown_to_adf(&md).unwrap();
20857        let inlines = rt.content[0].content.as_ref().unwrap();
20858        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20859        assert_eq!(types, vec!["text", "hardBreak", "text"]);
20860        assert_eq!(inlines[2].text.as_deref(), Some(" After"));
20861    }
20862
20863    #[test]
20864    fn issue_494_hardbreak_then_space_then_hardbreak_roundtrip() {
20865        // Space sandwiched between two hardBreaks.
20866        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
20867          {"type":"text","text":"A"},
20868          {"type":"hardBreak"},
20869          {"type":"text","text":" "},
20870          {"type":"hardBreak"},
20871          {"type":"text","text":"B"}
20872        ]}]}"#;
20873        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20874        let md = adf_to_markdown(&doc).unwrap();
20875        let rt = markdown_to_adf(&md).unwrap();
20876        let inlines = rt.content[0].content.as_ref().unwrap();
20877        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20878        assert_eq!(
20879            types,
20880            vec!["text", "hardBreak", "text", "hardBreak", "text"],
20881            "space between two hardBreaks should survive round-trip"
20882        );
20883        assert_eq!(inlines[2].text.as_deref(), Some(" "));
20884        assert_eq!(inlines[4].text.as_deref(), Some("B"));
20885    }
20886
20887    #[test]
20888    fn issue_494_trailing_space_hardbreak_style_not_confused() {
20889        // A plain paragraph break (blank line) should still work after
20890        // a line that does NOT end with a hardBreak marker.
20891        let md = "first paragraph\n\nsecond paragraph\n";
20892        let doc = markdown_to_adf(md).unwrap();
20893        assert_eq!(
20894            doc.content.len(),
20895            2,
20896            "blank line should still separate paragraphs"
20897        );
20898    }
20899
20900    #[test]
20901    fn issue_494_space_after_trailing_space_hardbreak_roundtrip() {
20902        // Same bug but with trailing-space style hardBreak (two spaces
20903        // before newline) instead of backslash style.
20904        let md = "line one  \n   \n";
20905        // The above is: "line one" + trailing-space hardBreak + continuation
20906        // line "   " (2-space indent + 1 space content).  The space-only
20907        // continuation should not be treated as a blank paragraph break.
20908        let doc = markdown_to_adf(md).unwrap();
20909        let inlines = doc.content[0].content.as_ref().unwrap();
20910        let has_text_after_break = inlines.iter().any(|n| {
20911            n.node_type == "text"
20912                && n.text
20913                    .as_deref()
20914                    .is_some_and(|t| t.trim().is_empty() && !t.is_empty())
20915        });
20916        assert!(
20917            has_text_after_break || inlines.len() >= 2,
20918            "space-only line after trailing-space hardBreak should be preserved"
20919        );
20920    }
20921
20922    #[test]
20923    fn issue_494_space_after_hardbreak_in_list_item_roundtrip() {
20924        // Exercises the same bug inside a list item context.
20925        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20926          {"type":"listItem","content":[{"type":"paragraph","content":[
20927            {"type":"text","text":"item"},
20928            {"type":"hardBreak"},
20929            {"type":"text","text":" "}
20930          ]}]}
20931        ]}]}"#;
20932        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20933        let md = adf_to_markdown(&doc).unwrap();
20934        let rt = markdown_to_adf(&md).unwrap();
20935        let list = &rt.content[0];
20936        let item = &list.content.as_ref().unwrap()[0];
20937        let para = &item.content.as_ref().unwrap()[0];
20938        let inlines = para.content.as_ref().unwrap();
20939        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20940        assert_eq!(
20941            types,
20942            vec!["text", "hardBreak", "text"],
20943            "space after hardBreak in list item should survive round-trip"
20944        );
20945        assert_eq!(inlines[2].text.as_deref(), Some(" "));
20946    }
20947
20948    // ── Issue #510: trailing spaces in text node should not become hardBreak ──
20949
20950    #[test]
20951    fn issue_510_trailing_double_space_paragraph_roundtrip() {
20952        // Two trailing spaces in a text node must survive round-trip without
20953        // being converted to a hardBreak or merging the next paragraph.
20954        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"}]}]}"#;
20955        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20956        let md = adf_to_markdown(&doc).unwrap();
20957        let rt = markdown_to_adf(&md).unwrap();
20958
20959        // Must produce two separate paragraphs
20960        assert_eq!(
20961            rt.content.len(),
20962            2,
20963            "should produce two paragraphs, got: {}",
20964            rt.content.len()
20965        );
20966        assert_eq!(rt.content[0].node_type, "paragraph");
20967        assert_eq!(rt.content[1].node_type, "paragraph");
20968
20969        // First paragraph text preserves trailing spaces
20970        let p1 = rt.content[0].content.as_ref().unwrap();
20971        assert_eq!(
20972            p1[0].text.as_deref(),
20973            Some("first paragraph with trailing spaces  "),
20974            "trailing spaces should be preserved in first paragraph"
20975        );
20976
20977        // Second paragraph is intact
20978        let p2 = rt.content[1].content.as_ref().unwrap();
20979        assert_eq!(p2[0].text.as_deref(), Some("second paragraph"));
20980
20981        // No hardBreak nodes should exist
20982        let all_types: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
20983        assert!(
20984            !all_types.contains(&"hardBreak"),
20985            "trailing spaces should not produce hardBreak, got: {all_types:?}"
20986        );
20987    }
20988
20989    #[test]
20990    fn issue_510_trailing_triple_space_roundtrip() {
20991        // Three trailing spaces also must not become a hardBreak.
20992        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"text   "}]},{"type":"paragraph","content":[{"type":"text","text":"next"}]}]}"#;
20993        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20994        let md = adf_to_markdown(&doc).unwrap();
20995        let rt = markdown_to_adf(&md).unwrap();
20996
20997        assert_eq!(rt.content.len(), 2, "should still be two paragraphs");
20998        let p1 = rt.content[0].content.as_ref().unwrap();
20999        assert_eq!(
21000            p1[0].text.as_deref(),
21001            Some("text   "),
21002            "three trailing spaces should be preserved"
21003        );
21004    }
21005
21006    #[test]
21007    fn issue_510_trailing_spaces_with_backslash_roundtrip() {
21008        // Text ending with backslash + trailing spaces: both must survive.
21009        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"end\\  "}]}]}"#;
21010        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21011        let md = adf_to_markdown(&doc).unwrap();
21012        let rt = markdown_to_adf(&md).unwrap();
21013        let p = rt.content[0].content.as_ref().unwrap();
21014        assert_eq!(
21015            p[0].text.as_deref(),
21016            Some("end\\  "),
21017            "backslash + trailing spaces should both survive"
21018        );
21019    }
21020
21021    #[test]
21022    fn issue_510_jfm_contains_escaped_trailing_space() {
21023        // Verify the serializer actually emits the backslash-space escape.
21024        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello  "}]}]}"#;
21025        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21026        let md = adf_to_markdown(&doc).unwrap();
21027        assert!(
21028            md.contains(r"\ "),
21029            "JFM should contain backslash-space escape for trailing spaces, got: {md:?}"
21030        );
21031        // Must NOT end with two plain spaces before newline
21032        for line in md.lines() {
21033            assert!(
21034                !line.ends_with("  "),
21035                "no JFM line should end with two plain spaces, got: {line:?}"
21036            );
21037        }
21038    }
21039
21040    #[test]
21041    fn issue_510_single_trailing_space_not_escaped() {
21042        // A single trailing space should NOT be escaped (not a hardBreak trigger).
21043        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"word "}]}]}"#;
21044        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21045        let md = adf_to_markdown(&doc).unwrap();
21046        assert!(
21047            !md.contains('\\'),
21048            "single trailing space should not be escaped, got: {md:?}"
21049        );
21050        let rt = markdown_to_adf(&md).unwrap();
21051        let p = rt.content[0].content.as_ref().unwrap();
21052        assert_eq!(p[0].text.as_deref(), Some("word "));
21053    }
21054
21055    #[test]
21056    fn issue_510_trailing_spaces_in_heading_roundtrip() {
21057        // Trailing double-spaces in a heading text node should also survive.
21058        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"heading  "}]}]}"#;
21059        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21060        let md = adf_to_markdown(&doc).unwrap();
21061        let rt = markdown_to_adf(&md).unwrap();
21062        let h = rt.content[0].content.as_ref().unwrap();
21063        assert_eq!(
21064            h[0].text.as_deref(),
21065            Some("heading  "),
21066            "trailing spaces in heading should be preserved"
21067        );
21068    }
21069
21070    #[test]
21071    fn issue_510_trailing_spaces_in_list_item_roundtrip() {
21072        // Trailing double-spaces in a bullet list item text node.
21073        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"item  "}]}]}]}]}"#;
21074        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21075        let md = adf_to_markdown(&doc).unwrap();
21076        let rt = markdown_to_adf(&md).unwrap();
21077        let list = &rt.content[0];
21078        let item = &list.content.as_ref().unwrap()[0];
21079        let para = &item.content.as_ref().unwrap()[0];
21080        let inlines = para.content.as_ref().unwrap();
21081        assert_eq!(
21082            inlines[0].text.as_deref(),
21083            Some("item  "),
21084            "trailing spaces in list item should be preserved"
21085        );
21086    }
21087
21088    #[test]
21089    fn issue_510_trailing_spaces_with_bold_mark_roundtrip() {
21090        // Trailing spaces in a bold-marked text node: the closing **
21091        // comes after the spaces, so the line doesn't end with spaces.
21092        // But the escape should still be applied (and be harmless).
21093        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"bold  ","marks":[{"type":"strong"}]}]}]}"#;
21094        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21095        let md = adf_to_markdown(&doc).unwrap();
21096        let rt = markdown_to_adf(&md).unwrap();
21097        let p = rt.content[0].content.as_ref().unwrap();
21098        assert_eq!(
21099            p[0].text.as_deref(),
21100            Some("bold  "),
21101            "trailing spaces in bold text should be preserved"
21102        );
21103    }
21104
21105    #[test]
21106    fn issue_510_hardbreak_between_paragraphs_still_works() {
21107        // Actual hardBreak nodes must still round-trip correctly.
21108        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"line one"},{"type":"hardBreak"},{"type":"text","text":"line two"}]}]}"#;
21109        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21110        let md = adf_to_markdown(&doc).unwrap();
21111        let rt = markdown_to_adf(&md).unwrap();
21112        let inlines = rt.content[0].content.as_ref().unwrap();
21113        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21114        assert_eq!(
21115            types,
21116            vec!["text", "hardBreak", "text"],
21117            "explicit hardBreak should still round-trip"
21118        );
21119    }
21120
21121    #[test]
21122    fn issue_510_all_spaces_text_node_roundtrip() {
21123        // A text node that is entirely spaces (2+) should survive.
21124        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"  "}]}]}"#;
21125        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21126        let md = adf_to_markdown(&doc).unwrap();
21127        let rt = markdown_to_adf(&md).unwrap();
21128        let p = rt.content[0].content.as_ref().unwrap();
21129        assert_eq!(
21130            p[0].text.as_deref(),
21131            Some("  "),
21132            "space-only text node should survive round-trip"
21133        );
21134    }
21135
21136    // ── Issue #522: listItem multi-paragraph merge ──────────────────────
21137
21138    #[test]
21139    fn issue_522_listitem_hardbreak_then_two_paragraphs_roundtrips() {
21140        // The exact reproducer from issue #522: first paragraph has
21141        // hardBreak nodes, followed by two sibling paragraphs.
21142        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"}]}]}]}]}"#;
21143        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21144        let md = adf_to_markdown(&doc).unwrap();
21145        let rt = markdown_to_adf(&md).unwrap();
21146
21147        let items = rt.content[0].content.as_ref().unwrap();
21148        assert_eq!(items.len(), 1);
21149        let children = items[0].content.as_ref().unwrap();
21150        assert_eq!(
21151            children.len(),
21152            3,
21153            "Expected 3 paragraphs in listItem, got {}",
21154            children.len()
21155        );
21156        assert_eq!(children[0].node_type, "paragraph");
21157        assert_eq!(children[1].node_type, "paragraph");
21158        assert_eq!(children[2].node_type, "paragraph");
21159
21160        // Verify the text content of each paragraph
21161        let text1 = children[1].content.as_ref().unwrap()[0]
21162            .text
21163            .as_deref()
21164            .unwrap();
21165        assert_eq!(text1, "second paragraph");
21166        let text2 = children[2].content.as_ref().unwrap()[0]
21167            .text
21168            .as_deref()
21169            .unwrap();
21170        assert_eq!(text2, "third paragraph");
21171    }
21172
21173    #[test]
21174    fn issue_522_ordered_list_hardbreak_then_paragraphs_roundtrips() {
21175        // Same scenario in an ordered list.
21176        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"}]}]}]}]}"#;
21177        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21178        let md = adf_to_markdown(&doc).unwrap();
21179        let rt = markdown_to_adf(&md).unwrap();
21180
21181        let items = rt.content[0].content.as_ref().unwrap();
21182        let children = items[0].content.as_ref().unwrap();
21183        assert_eq!(
21184            children.len(),
21185            3,
21186            "Expected 3 paragraphs in ordered listItem, got {}",
21187            children.len()
21188        );
21189        assert_eq!(children[1].node_type, "paragraph");
21190        assert_eq!(children[2].node_type, "paragraph");
21191        assert_eq!(
21192            children[1].content.as_ref().unwrap()[0]
21193                .text
21194                .as_deref()
21195                .unwrap(),
21196            "second para"
21197        );
21198        assert_eq!(
21199            children[2].content.as_ref().unwrap()[0]
21200                .text
21201                .as_deref()
21202                .unwrap(),
21203            "third para"
21204        );
21205    }
21206
21207    #[test]
21208    fn issue_522_two_paragraphs_without_hardbreak_roundtrips() {
21209        // Two paragraphs without hardBreak — should also remain separate.
21210        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"}]}]}]}]}"#;
21211        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21212        let md = adf_to_markdown(&doc).unwrap();
21213        let rt = markdown_to_adf(&md).unwrap();
21214
21215        let items = rt.content[0].content.as_ref().unwrap();
21216        let children = items[0].content.as_ref().unwrap();
21217        assert_eq!(
21218            children.len(),
21219            2,
21220            "Expected 2 paragraphs in listItem, got {}",
21221            children.len()
21222        );
21223        assert_eq!(children[0].node_type, "paragraph");
21224        assert_eq!(children[1].node_type, "paragraph");
21225    }
21226
21227    #[test]
21228    fn issue_522_paragraph_then_nested_list_no_spurious_blank() {
21229        // A paragraph followed by a nested list should NOT get a blank
21230        // separator (only paragraph-paragraph transitions need one).
21231        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"}]}]}]}]}]}]}"#;
21232        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21233        let md = adf_to_markdown(&doc).unwrap();
21234        // Should not contain a blank indented line between parent text and sub-list
21235        assert!(
21236            !md.contains("  \n  -"),
21237            "No blank separator between paragraph and nested list"
21238        );
21239        let rt = markdown_to_adf(&md).unwrap();
21240
21241        let items = rt.content[0].content.as_ref().unwrap();
21242        let children = items[0].content.as_ref().unwrap();
21243        assert_eq!(children.len(), 2);
21244        assert_eq!(children[0].node_type, "paragraph");
21245        assert_eq!(children[1].node_type, "bulletList");
21246    }
21247
21248    #[test]
21249    fn issue_522_three_paragraphs_no_hardbreak_roundtrips() {
21250        // Three plain paragraphs (no hardBreak) inside a single listItem.
21251        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"}]}]}]}]}"#;
21252        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21253        let md = adf_to_markdown(&doc).unwrap();
21254        let rt = markdown_to_adf(&md).unwrap();
21255
21256        let items = rt.content[0].content.as_ref().unwrap();
21257        let children = items[0].content.as_ref().unwrap();
21258        assert_eq!(
21259            children.len(),
21260            3,
21261            "Expected 3 paragraphs, got {}",
21262            children.len()
21263        );
21264        for (i, child) in children.iter().enumerate() {
21265            assert_eq!(
21266                child.node_type, "paragraph",
21267                "Child {} should be a paragraph",
21268                i
21269            );
21270        }
21271    }
21272
21273    #[test]
21274    fn issue_522_multiple_list_items_each_with_paragraphs() {
21275        // Multiple list items, each with multiple paragraphs.
21276        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"}]}]}]}]}"#;
21277        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21278        let md = adf_to_markdown(&doc).unwrap();
21279        let rt = markdown_to_adf(&md).unwrap();
21280
21281        let items = rt.content[0].content.as_ref().unwrap();
21282        assert_eq!(items.len(), 2, "Expected 2 list items");
21283
21284        let item1 = items[0].content.as_ref().unwrap();
21285        assert_eq!(item1.len(), 2, "Item 1 should have 2 paragraphs");
21286
21287        let item2 = items[1].content.as_ref().unwrap();
21288        assert_eq!(item2.len(), 2, "Item 2 should have 2 paragraphs");
21289        // Verify hardBreak is preserved in item2's first paragraph
21290        let item2_p1_inlines = item2[0].content.as_ref().unwrap();
21291        let types: Vec<&str> = item2_p1_inlines
21292            .iter()
21293            .map(|n| n.node_type.as_str())
21294            .collect();
21295        assert_eq!(types, vec!["text", "hardBreak", "text"]);
21296    }
21297
21298    #[test]
21299    fn issue_531_blockquote_hardbreak_then_two_paragraphs_roundtrips() {
21300        // The exact reproducer from issue #531: blockquote with first
21301        // paragraph containing hardBreak nodes, followed by two sibling
21302        // paragraphs.
21303        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"}]}]}]}"#;
21304        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21305        let md = adf_to_markdown(&doc).unwrap();
21306        let rt = markdown_to_adf(&md).unwrap();
21307
21308        let children = rt.content[0].content.as_ref().unwrap();
21309        assert_eq!(
21310            children.len(),
21311            3,
21312            "Expected 3 paragraphs in blockquote, got {}",
21313            children.len()
21314        );
21315        assert_eq!(children[0].node_type, "paragraph");
21316        assert_eq!(children[1].node_type, "paragraph");
21317        assert_eq!(children[2].node_type, "paragraph");
21318
21319        let text1 = children[1].content.as_ref().unwrap()[0]
21320            .text
21321            .as_deref()
21322            .unwrap();
21323        assert_eq!(text1, "second paragraph");
21324        let text2 = children[2].content.as_ref().unwrap()[0]
21325            .text
21326            .as_deref()
21327            .unwrap();
21328        assert_eq!(text2, "third paragraph");
21329    }
21330
21331    #[test]
21332    fn issue_531_blockquote_two_paragraphs_without_hardbreak_roundtrips() {
21333        // Two simple paragraphs inside a blockquote, no hardBreak.
21334        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"}]}]}]}"#;
21335        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21336        let md = adf_to_markdown(&doc).unwrap();
21337        let rt = markdown_to_adf(&md).unwrap();
21338
21339        let children = rt.content[0].content.as_ref().unwrap();
21340        assert_eq!(
21341            children.len(),
21342            2,
21343            "Expected 2 paragraphs in blockquote, got {}",
21344            children.len()
21345        );
21346        assert_eq!(children[0].node_type, "paragraph");
21347        assert_eq!(children[1].node_type, "paragraph");
21348    }
21349
21350    #[test]
21351    fn issue_531_blockquote_three_paragraphs_no_hardbreak_roundtrips() {
21352        // Three paragraphs, none with hardBreak.
21353        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"}]}]}]}"#;
21354        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21355        let md = adf_to_markdown(&doc).unwrap();
21356        let rt = markdown_to_adf(&md).unwrap();
21357
21358        let children = rt.content[0].content.as_ref().unwrap();
21359        assert_eq!(
21360            children.len(),
21361            3,
21362            "Expected 3 paragraphs in blockquote, got {}",
21363            children.len()
21364        );
21365        for child in children {
21366            assert_eq!(child.node_type, "paragraph");
21367        }
21368    }
21369
21370    #[test]
21371    fn issue_531_blockquote_paragraph_then_list_no_spurious_blank() {
21372        // A paragraph followed by a nested list inside a blockquote —
21373        // should NOT insert a blank separator line.
21374        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"}]}]}]}]}]}"#;
21375        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21376        let md = adf_to_markdown(&doc).unwrap();
21377        let rt = markdown_to_adf(&md).unwrap();
21378
21379        let children = rt.content[0].content.as_ref().unwrap();
21380        assert_eq!(children[0].node_type, "paragraph");
21381        assert_eq!(children[1].node_type, "bulletList");
21382    }
21383
21384    #[test]
21385    fn issue_531_blockquote_single_paragraph_unchanged() {
21386        // A single paragraph in a blockquote should remain unchanged.
21387        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"solo"}]}]}]}"#;
21388        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21389        let md = adf_to_markdown(&doc).unwrap();
21390        let rt = markdown_to_adf(&md).unwrap();
21391
21392        let children = rt.content[0].content.as_ref().unwrap();
21393        assert_eq!(children.len(), 1);
21394        assert_eq!(children[0].node_type, "paragraph");
21395        let text = children[0].content.as_ref().unwrap()[0]
21396            .text
21397            .as_deref()
21398            .unwrap();
21399        assert_eq!(text, "solo");
21400    }
21401
21402    // ── Issue #554: marks combined with `code` or with each other ──────
21403
21404    /// Helper: roundtrip an ADF document and assert the marks on the first
21405    /// text node match `expected_marks` (in order).
21406    fn assert_roundtrip_marks(adf_json: &str, expected_marks: &[&str]) {
21407        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21408        let md = adf_to_markdown(&doc).unwrap();
21409        let rt = markdown_to_adf(&md).unwrap();
21410        let node = &rt.content[0].content.as_ref().unwrap()[0];
21411        let mark_types: Vec<&str> = node
21412            .marks
21413            .as_ref()
21414            .expect("should have marks")
21415            .iter()
21416            .map(|m| m.mark_type.as_str())
21417            .collect();
21418        assert_eq!(
21419            mark_types, expected_marks,
21420            "mark order mismatch for md={md}"
21421        );
21422    }
21423
21424    #[test]
21425    fn issue_554_code_and_text_color_preserved() {
21426        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21427          {"type":"text","text":"x","marks":[
21428            {"type":"textColor","attrs":{"color":"#008000"}},
21429            {"type":"code"}
21430          ]}
21431        ]}]}"##;
21432        assert_roundtrip_marks(adf_json, &["textColor", "code"]);
21433    }
21434
21435    #[test]
21436    fn issue_554_code_and_bg_color_preserved() {
21437        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21438          {"type":"text","text":"x","marks":[
21439            {"type":"backgroundColor","attrs":{"color":"#FF0000"}},
21440            {"type":"code"}
21441          ]}
21442        ]}]}"##;
21443        assert_roundtrip_marks(adf_json, &["backgroundColor", "code"]);
21444    }
21445
21446    #[test]
21447    fn issue_554_code_and_subsup_preserved() {
21448        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21449          {"type":"text","text":"x","marks":[
21450            {"type":"subsup","attrs":{"type":"sub"}},
21451            {"type":"code"}
21452          ]}
21453        ]}]}"#;
21454        assert_roundtrip_marks(adf_json, &["subsup", "code"]);
21455    }
21456
21457    #[test]
21458    fn issue_554_code_and_underline_preserved() {
21459        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21460          {"type":"text","text":"x","marks":[
21461            {"type":"underline"},
21462            {"type":"code"}
21463          ]}
21464        ]}]}"#;
21465        assert_roundtrip_marks(adf_json, &["underline", "code"]);
21466    }
21467
21468    #[test]
21469    fn issue_554_code_textcolor_and_underline_preserved() {
21470        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21471          {"type":"text","text":"x","marks":[
21472            {"type":"textColor","attrs":{"color":"#008000"}},
21473            {"type":"underline"},
21474            {"type":"code"}
21475          ]}
21476        ]}]}"##;
21477        assert_roundtrip_marks(adf_json, &["textColor", "underline", "code"]);
21478    }
21479
21480    #[test]
21481    fn issue_554_textcolor_and_underline_preserved() {
21482        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21483          {"type":"text","text":"x","marks":[
21484            {"type":"textColor","attrs":{"color":"#008000"}},
21485            {"type":"underline"}
21486          ]}
21487        ]}]}"##;
21488        assert_roundtrip_marks(adf_json, &["textColor", "underline"]);
21489    }
21490
21491    #[test]
21492    fn issue_554_underline_and_textcolor_preserved_order_swapped() {
21493        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21494          {"type":"text","text":"x","marks":[
21495            {"type":"underline"},
21496            {"type":"textColor","attrs":{"color":"#008000"}}
21497          ]}
21498        ]}]}"##;
21499        // underline appears first, so it should be the OUTER wrapper.
21500        assert_roundtrip_marks(adf_json, &["underline", "textColor"]);
21501    }
21502
21503    #[test]
21504    fn issue_554_textcolor_and_annotation_preserved() {
21505        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21506          {"type":"text","text":"x","marks":[
21507            {"type":"textColor","attrs":{"color":"#008000"}},
21508            {"type":"annotation","attrs":{"id":"abc-123","annotationType":"inlineComment"}}
21509          ]}
21510        ]}]}"##;
21511        assert_roundtrip_marks(adf_json, &["textColor", "annotation"]);
21512    }
21513
21514    #[test]
21515    fn issue_554_bgcolor_and_underline_preserved() {
21516        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21517          {"type":"text","text":"x","marks":[
21518            {"type":"backgroundColor","attrs":{"color":"#FF0000"}},
21519            {"type":"underline"}
21520          ]}
21521        ]}]}"##;
21522        assert_roundtrip_marks(adf_json, &["backgroundColor", "underline"]);
21523    }
21524
21525    #[test]
21526    fn issue_554_subsup_and_underline_preserved() {
21527        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21528          {"type":"text","text":"x","marks":[
21529            {"type":"subsup","attrs":{"type":"sub"}},
21530            {"type":"underline"}
21531          ]}
21532        ]}]}"#;
21533        assert_roundtrip_marks(adf_json, &["subsup", "underline"]);
21534    }
21535
21536    #[test]
21537    fn issue_554_exact_reproducer_full_match() {
21538        // The exact reproducer from issue #554. The byte-for-byte ADF JSON
21539        // must round-trip through `from-adf | to-adf` unchanged.
21540        let adf_json = r##"{
21541          "version": 1,
21542          "type": "doc",
21543          "content": [
21544            {
21545              "type": "paragraph",
21546              "content": [
21547                {"type":"text","text":"Status: ","marks":[{"type":"strong"}]},
21548                {"type":"text","text":"Approved","marks":[
21549                  {"type":"textColor","attrs":{"color":"#008000"}}
21550                ]},
21551                {"type":"text","text":" — ready to proceed"}
21552              ]
21553            }
21554          ]
21555        }"##;
21556        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21557        let md = adf_to_markdown(&doc).unwrap();
21558        assert!(
21559            md.contains(":span[Approved]{color=#008000}"),
21560            "JFM should contain green span: {md}"
21561        );
21562        let rt = markdown_to_adf(&md).unwrap();
21563        // Find the "Approved" text node and verify color is preserved.
21564        let approved = rt.content[0]
21565            .content
21566            .as_ref()
21567            .unwrap()
21568            .iter()
21569            .find(|n| n.text.as_deref() == Some("Approved"))
21570            .expect("Approved text node");
21571        let marks = approved.marks.as_ref().expect("should have marks");
21572        let color_mark = marks
21573            .iter()
21574            .find(|m| m.mark_type == "textColor")
21575            .expect("textColor mark must be preserved");
21576        assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#008000");
21577    }
21578
21579    #[test]
21580    fn issue_554_textcolor_with_code_renders_span_around_code() {
21581        // Verify the rendered JFM uses `:span[`text`]{color=...}` — the
21582        // syntax suggested in the issue.
21583        let doc = AdfDocument {
21584            version: 1,
21585            doc_type: "doc".to_string(),
21586            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
21587                "fn main",
21588                vec![
21589                    AdfMark::text_color("#008000"),
21590                    AdfMark {
21591                        mark_type: "code".to_string(),
21592                        attrs: None,
21593                    },
21594                ],
21595            )])],
21596        };
21597        let md = adf_to_markdown(&doc).unwrap();
21598        assert!(
21599            md.contains(":span[`fn main`]{color=#008000}"),
21600            "expected span-wrapped code, got: {md}"
21601        );
21602    }
21603
21604    #[test]
21605    fn issue_554_underline_with_code_renders_bracketed_around_code() {
21606        let doc = AdfDocument {
21607            version: 1,
21608            doc_type: "doc".to_string(),
21609            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
21610                "fn main",
21611                vec![
21612                    AdfMark::underline(),
21613                    AdfMark {
21614                        mark_type: "code".to_string(),
21615                        attrs: None,
21616                    },
21617                ],
21618            )])],
21619        };
21620        let md = adf_to_markdown(&doc).unwrap();
21621        assert!(
21622            md.contains("[`fn main`]{underline}"),
21623            "expected bracketed-span around code, got: {md}"
21624        );
21625    }
21626
21627    // ── Issue #554 (re-opened): boundary-underscore destroys span directives ──
21628
21629    #[test]
21630    fn issue_554_underscore_adjacent_to_textcolor_span_roundtrip() {
21631        // Reproducer from the re-opened issue: a `_ ` plain-text node followed
21632        // by a textColor span whose text starts with `_` produced JFM that the
21633        // parser saw as an italic delimiter pair, destroying the span and
21634        // losing the textColor mark entirely.
21635        let adf_json = r##"{
21636          "version": 1,
21637          "type": "doc",
21638          "content": [
21639            {
21640              "type": "paragraph",
21641              "content": [
21642                {"type":"text","text":"_ "},
21643                {"type":"text","text":"_Action:*","marks":[
21644                  {"type":"textColor","attrs":{"color":"#008000"}}
21645                ]},
21646                {"type":"text","text":" Complete the setup process."}
21647              ]
21648            }
21649          ]
21650        }"##;
21651        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21652        let md = adf_to_markdown(&doc).unwrap();
21653        // The leading `_` chars must be backslash-escaped so the parser
21654        // doesn't form a false italic pair across the span boundary.
21655        assert!(
21656            md.contains(r"\_ ") && md.contains(r":span[\_Action"),
21657            "underscores at node boundaries should be escaped: {md}"
21658        );
21659        let rt = markdown_to_adf(&md).unwrap();
21660        let para_content = rt.content[0].content.as_ref().unwrap();
21661        // Find the textColor-marked node.
21662        let colored = para_content
21663            .iter()
21664            .find(|n| {
21665                n.marks
21666                    .as_deref()
21667                    .is_some_and(|ms| ms.iter().any(|m| m.mark_type == "textColor"))
21668            })
21669            .expect("textColor node must be preserved");
21670        assert_eq!(colored.text.as_deref(), Some("_Action:*"));
21671        let color_mark = colored
21672            .marks
21673            .as_ref()
21674            .unwrap()
21675            .iter()
21676            .find(|m| m.mark_type == "textColor")
21677            .unwrap();
21678        assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#008000");
21679        // Verify no spurious em mark crept in.
21680        for n in para_content {
21681            if let Some(ms) = n.marks.as_deref() {
21682                assert!(
21683                    !ms.iter().any(|m| m.mark_type == "em"),
21684                    "no em mark should appear, got marks {:?}",
21685                    ms.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
21686                );
21687            }
21688        }
21689    }
21690
21691    #[test]
21692    fn issue_554_underscore_intraword_left_unescaped() {
21693        // Sanity check: ordinary intraword underscores like `do_something_useful`
21694        // should NOT be escaped — escaping would still round-trip correctly,
21695        // but produces noisy backslashes in the JFM output.
21696        let doc = AdfDocument {
21697            version: 1,
21698            doc_type: "doc".to_string(),
21699            content: vec![AdfNode::paragraph(vec![AdfNode::text(
21700                "call do_something_useful now",
21701            )])],
21702        };
21703        let md = adf_to_markdown(&doc).unwrap();
21704        assert!(
21705            md.contains("do_something_useful") && !md.contains(r"do\_something\_useful"),
21706            "intraword underscores should not be escaped: {md}"
21707        );
21708    }
21709
21710    #[test]
21711    fn issue_554_code_underline_then_textcolor_bracketed_outer() {
21712        // Mark order [underline, textColor, code] — bracketed-span outer,
21713        // span inner. Exercises wrap_with_attrs (true, true) !span_before.
21714        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21715          {"type":"text","text":"x","marks":[
21716            {"type":"underline"},
21717            {"type":"textColor","attrs":{"color":"#008000"}},
21718            {"type":"code"}
21719          ]}
21720        ]}]}"##;
21721        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21722        let md = adf_to_markdown(&doc).unwrap();
21723        // Bracketed-span should be the outermost wrapper.
21724        assert!(
21725            md.starts_with('[') && md.contains("underline}"),
21726            "bracketed-span should wrap the span, got: {md}"
21727        );
21728        let rt = markdown_to_adf(&md).unwrap();
21729        let node = &rt.content[0].content.as_ref().unwrap()[0];
21730        let mark_types: Vec<&str> = node
21731            .marks
21732            .as_ref()
21733            .unwrap()
21734            .iter()
21735            .map(|m| m.mark_type.as_str())
21736            .collect();
21737        assert_eq!(mark_types, vec!["underline", "textColor", "code"]);
21738    }
21739
21740    #[test]
21741    fn issue_554_textcolor_underline_link_all_preserved() {
21742        // Mark order [textColor, underline, link] — span outer, bracketed
21743        // wraps the link inside. Exercises the span-wraps-link-with-bracketed
21744        // branch.
21745        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21746          {"type":"text","text":"linked","marks":[
21747            {"type":"textColor","attrs":{"color":"#008000"}},
21748            {"type":"underline"},
21749            {"type":"link","attrs":{"href":"https://example.com"}}
21750          ]}
21751        ]}]}"##;
21752        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21753        let md = adf_to_markdown(&doc).unwrap();
21754        let rt = markdown_to_adf(&md).unwrap();
21755        let node = &rt.content[0].content.as_ref().unwrap()[0];
21756        let mark_types: Vec<&str> = node
21757            .marks
21758            .as_ref()
21759            .unwrap()
21760            .iter()
21761            .map(|m| m.mark_type.as_str())
21762            .collect();
21763        assert_eq!(mark_types, vec!["textColor", "underline", "link"]);
21764    }
21765
21766    #[test]
21767    fn issue_554_underline_textcolor_link_bracketed_outer_link_last() {
21768        // Mark order [underline, textColor, link] — bracketed-span outer of
21769        // both span and link. Exercises the bracketed-wraps-everything branch.
21770        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21771          {"type":"text","text":"linked","marks":[
21772            {"type":"underline"},
21773            {"type":"textColor","attrs":{"color":"#008000"}},
21774            {"type":"link","attrs":{"href":"https://example.com"}}
21775          ]}
21776        ]}]}"##;
21777        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21778        let md = adf_to_markdown(&doc).unwrap();
21779        let rt = markdown_to_adf(&md).unwrap();
21780        let node = &rt.content[0].content.as_ref().unwrap()[0];
21781        let mark_types: Vec<&str> = node
21782            .marks
21783            .as_ref()
21784            .unwrap()
21785            .iter()
21786            .map(|m| m.mark_type.as_str())
21787            .collect();
21788        assert_eq!(mark_types, vec!["underline", "textColor", "link"]);
21789    }
21790
21791    #[test]
21792    fn issue_554_link_underline_textcolor_link_outer() {
21793        // Mark order [link, underline, textColor] — link outermost, wraps a
21794        // bracketed-span that wraps the span. Exercises the link-wraps-
21795        // bracketed-wraps-span branch.
21796        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21797          {"type":"text","text":"linked","marks":[
21798            {"type":"link","attrs":{"href":"https://example.com"}},
21799            {"type":"underline"},
21800            {"type":"textColor","attrs":{"color":"#008000"}}
21801          ]}
21802        ]}]}"##;
21803        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21804        let md = adf_to_markdown(&doc).unwrap();
21805        assert!(
21806            md.starts_with('[') && md.contains("](https://example.com)"),
21807            "link should be outermost, got: {md}"
21808        );
21809        let rt = markdown_to_adf(&md).unwrap();
21810        let node = &rt.content[0].content.as_ref().unwrap()[0];
21811        let mark_types: Vec<&str> = node
21812            .marks
21813            .as_ref()
21814            .unwrap()
21815            .iter()
21816            .map(|m| m.mark_type.as_str())
21817            .collect();
21818        assert_eq!(mark_types, vec!["link", "underline", "textColor"]);
21819    }
21820
21821    #[test]
21822    fn issue_554_trailing_underscore_then_leading_underscore_round_trip() {
21823        // Two adjacent text nodes where the first ends with `_` and the
21824        // second starts with `_` — without escaping, the JFM parser sees
21825        // an `_..._` pair spanning the boundary.
21826        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21827          {"type":"text","text":"end_"},
21828          {"type":"text","text":"_start"}
21829        ]}]}"#;
21830        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21831        let md = adf_to_markdown(&doc).unwrap();
21832        let rt = markdown_to_adf(&md).unwrap();
21833        // Reassemble all text in the paragraph.
21834        let combined: String = rt.content[0]
21835            .content
21836            .as_ref()
21837            .unwrap()
21838            .iter()
21839            .filter_map(|n| n.text.as_deref())
21840            .collect();
21841        assert_eq!(combined, "end__start");
21842        // No node should have an em mark.
21843        for n in rt.content[0].content.as_ref().unwrap() {
21844            if let Some(ms) = n.marks.as_deref() {
21845                assert!(!ms.iter().any(|m| m.mark_type == "em"));
21846            }
21847        }
21848    }
21849}