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("DECIDED", inline_nodes));
1255        }
1256    }
1257    items
1258}
1259
1260/// Tries to parse a task list marker `[ ]`, `[x]`, or `[X]` at the start of text.
1261/// Returns `("TODO"|"DONE", remaining_text)` on success.
1262///
1263/// The marker must be followed by a space or the end of the text, so that
1264/// empty task items (`- [ ]` with no body) are still recognised as tasks
1265/// rather than being treated as bullet items containing literal `[ ]` text
1266/// (issue #548).
1267fn try_parse_task_marker(text: &str) -> Option<(&str, &str)> {
1268    if let Some(rest) = strip_task_checkbox(text, "[ ]") {
1269        Some(("TODO", rest))
1270    } else if let Some(rest) =
1271        strip_task_checkbox(text, "[x]").or_else(|| strip_task_checkbox(text, "[X]"))
1272    {
1273        Some(("DONE", rest))
1274    } else {
1275        None
1276    }
1277}
1278
1279/// Strips a checkbox prefix from `text` if the character after the checkbox
1280/// is a space or the text ends there.  Returns the remaining text (with the
1281/// separating space consumed, if any).
1282fn strip_task_checkbox<'a>(text: &'a str, checkbox: &str) -> Option<&'a str> {
1283    let rest = text.strip_prefix(checkbox)?;
1284    if rest.is_empty() {
1285        Some(rest)
1286    } else {
1287        rest.strip_prefix(' ')
1288    }
1289}
1290
1291/// Returns true if `s` begins with a sequence the bullet-list parser would
1292/// interpret as a task checkbox marker (`[ ]`, `[x]`, or `[X]` followed by
1293/// a space, newline, or end-of-input).
1294///
1295/// Used by the `bulletList` renderer to decide whether to escape the leading
1296/// `[` of an item whose literal text starts with a checkbox-shaped prefix
1297/// (issue #548).
1298fn starts_with_task_marker(s: &str) -> bool {
1299    let after = if let Some(rest) = s.strip_prefix("[ ]") {
1300        rest
1301    } else if let Some(rest) = s.strip_prefix("[x]").or_else(|| s.strip_prefix("[X]")) {
1302        rest
1303    } else {
1304        return false;
1305    };
1306    after.is_empty() || after.starts_with(' ') || after.starts_with('\n')
1307}
1308
1309/// Parses an ordered list marker like "1. " and returns (number, rest_of_line).
1310fn parse_ordered_list_marker(line: &str) -> Option<(u32, &str)> {
1311    let digit_end = line.find(|c: char| !c.is_ascii_digit())?;
1312    if digit_end == 0 {
1313        return None;
1314    }
1315    let rest = &line[digit_end..];
1316    let after_marker = rest.strip_prefix(". ")?;
1317    let num: u32 = line[..digit_end].parse().ok()?;
1318    Some((num, after_marker))
1319}
1320
1321/// Returns true if a line ends with a hardBreak marker
1322/// (trailing backslash or two trailing spaces).
1323fn has_trailing_hard_break(line: &str) -> bool {
1324    line.ends_with('\\') || line.ends_with("  ")
1325}
1326
1327/// Returns true if the already-trimmed continuation line starts with a
1328/// block-level marker that must not be swallowed as a paragraph continuation
1329/// in `collect_hardbreak_continuations`.
1330///
1331/// Covers mediaSingle (`![` — issue #490), fenced code blocks (```` ``` ````
1332/// — issue #552), and container directives (`:::`).  The caller is expected
1333/// to have already stripped leading whitespace.
1334fn is_block_level_continuation_marker(trimmed: &str) -> bool {
1335    trimmed.starts_with("![") || trimmed.starts_with("```") || trimmed.starts_with(":::")
1336}
1337
1338/// Checks if a line starts a list item.
1339fn is_list_start(line: &str) -> bool {
1340    let trimmed = line.trim_start();
1341    trimmed.starts_with("- ")
1342        || trimmed.starts_with("* ")
1343        || trimmed.starts_with("+ ")
1344        || parse_ordered_list_marker(trimmed).is_some()
1345}
1346
1347/// Escapes asterisk and underscore sequences in text that would otherwise be
1348/// parsed as CommonMark emphasis (`*…*`, `_…_`) or strong emphasis (`**…**`,
1349/// `__…__`).
1350///
1351/// Asterisks are always escaped (they're rare in prose and the JFM parser
1352/// will gladly match them across node boundaries). Underscores are escaped
1353/// per the intraword rule: a `_` is left as-is only when it's clearly
1354/// intraword *within this text node* (alphanumeric on both sides). At the
1355/// node boundary or next to non-alphanumeric characters we escape, since
1356/// adjacent text nodes can supply the other side of an emphasis pair (issue
1357/// #554: `"_ "` followed by colored `"_Action…"` produced `_ :span[_…` which
1358/// parsed as italic and destroyed the span directive).
1359fn escape_emphasis_markers(text: &str) -> String {
1360    escape_emphasis_with(text, false)
1361}
1362
1363/// Variant of [`escape_emphasis_markers`] that escapes ALL underscores (even
1364/// intraword), not just boundary ones.
1365///
1366/// Must be used whenever the rendered markdown wraps this text in an `_..._`
1367/// em delimiter, because an unescaped `_` anywhere in the content would
1368/// otherwise close the delimiter prematurely (e.g. `_foo_bar_baz_` parses as
1369/// em("foo") + "bar" + em("baz"), not em("foo_bar_baz")).
1370fn escape_emphasis_markers_with_underscore(text: &str) -> String {
1371    escape_emphasis_with(text, true)
1372}
1373
1374/// Internal: escapes `*` always, and escapes `_` per the CommonMark intraword
1375/// rule by default — boundary or punctuation-adjacent runs are escaped, fully
1376/// intraword runs are left as-is.  When `escape_underscore_always` is true,
1377/// every `_` is escaped regardless (used when the surrounding context is an
1378/// `_..._` em delimiter that any inner `_` would close prematurely).
1379fn escape_emphasis_with(text: &str, escape_underscore_always: bool) -> String {
1380    let chars: Vec<char> = text.chars().collect();
1381    let mut out = String::with_capacity(text.len());
1382    let mut idx = 0;
1383    while idx < chars.len() {
1384        let ch = chars[idx];
1385        if ch == '*' {
1386            out.push('\\');
1387            out.push(ch);
1388            idx += 1;
1389        } else if ch == '_' {
1390            // Find the extent of this run of underscores. CommonMark treats
1391            // consecutive `_` as a single delimiter run, so the intraword
1392            // check applies to the whole run, not individual characters.
1393            let run_start = idx;
1394            let mut run_end = idx;
1395            while run_end < chars.len() && chars[run_end] == '_' {
1396                run_end += 1;
1397            }
1398            let escape_run = if escape_underscore_always {
1399                true
1400            } else {
1401                let before_alnum = run_start > 0 && chars[run_start - 1].is_alphanumeric();
1402                let after_alnum = chars.get(run_end).is_some_and(|c| c.is_alphanumeric());
1403                !(before_alnum && after_alnum)
1404            };
1405            for _ in run_start..run_end {
1406                if escape_run {
1407                    out.push('\\');
1408                }
1409                out.push('_');
1410            }
1411            idx = run_end;
1412        } else {
1413            out.push(ch);
1414            idx += 1;
1415        }
1416    }
1417    out
1418}
1419
1420/// Escapes backtick characters in text that would otherwise be parsed as
1421/// inline code spans (`` `…` ``).
1422///
1423/// Each backtick is prefixed with a backslash so that the JFM parser treats
1424/// it as a literal character rather than an inline-code delimiter.
1425fn escape_backticks(text: &str) -> String {
1426    let mut out = String::with_capacity(text.len());
1427    for ch in text.chars() {
1428        if ch == '`' {
1429            out.push('\\');
1430        }
1431        out.push(ch);
1432    }
1433    out
1434}
1435
1436/// Chooses a backtick delimiter length and padding flag for rendering `text`
1437/// as a CommonMark inline code span.
1438///
1439/// Per CommonMark: the delimiter must be a run of backticks not equal in
1440/// length to any run inside the content, and if both ends of the content
1441/// would start/end with a space (or with a backtick), a single space of
1442/// padding is added so the span survives the spec's space-stripping rule.
1443fn inline_code_delimiter(text: &str) -> (usize, bool) {
1444    let mut max_run = 0usize;
1445    let mut current = 0usize;
1446    for ch in text.chars() {
1447        if ch == '`' {
1448            current += 1;
1449            if current > max_run {
1450                max_run = current;
1451            }
1452        } else {
1453            current = 0;
1454        }
1455    }
1456    let n = max_run + 1;
1457    let starts_bt = text.starts_with('`');
1458    let ends_bt = text.ends_with('`');
1459    let starts_sp = text.starts_with(' ');
1460    let ends_sp = text.ends_with(' ');
1461    let all_sp = !text.is_empty() && text.chars().all(|c| c == ' ');
1462    let needs_pad = starts_bt || ends_bt || (starts_sp && ends_sp && !all_sp);
1463    (n, needs_pad)
1464}
1465
1466/// Appends `text` to `output` wrapped in a CommonMark inline code span whose
1467/// delimiter length allows any embedded backticks to round-trip unambiguously.
1468fn render_inline_code(text: &str, output: &mut String) {
1469    let (n, pad) = inline_code_delimiter(text);
1470    for _ in 0..n {
1471        output.push('`');
1472    }
1473    if pad {
1474        output.push(' ');
1475    }
1476    output.push_str(text);
1477    if pad {
1478        output.push(' ');
1479    }
1480    for _ in 0..n {
1481        output.push('`');
1482    }
1483}
1484
1485/// Escapes pipe characters in text that appears inside a GFM pipe table cell.
1486///
1487/// Without this, a literal `|` in cell content (including inside inline code
1488/// spans) is interpreted as a column separator on round-trip, splitting the
1489/// cell and corrupting its content (see issue #579).  Each `|` is prefixed
1490/// with a backslash so the table-row parser treats it as literal.
1491fn escape_pipes_in_cell(text: &str) -> String {
1492    let mut out = String::with_capacity(text.len());
1493    for ch in text.chars() {
1494        if ch == '|' {
1495            out.push('\\');
1496        }
1497        out.push(ch);
1498    }
1499    out
1500}
1501
1502/// Escapes square brackets (`[` and `]`) in text that will appear inside a
1503/// markdown link's `[…]` delimiters.  Without this, a text node containing a
1504/// literal `[` or `]` can create ambiguous markdown link syntax on round-trip
1505/// (see issue #493).
1506fn escape_link_brackets(text: &str) -> String {
1507    let mut out = String::with_capacity(text.len());
1508    for ch in text.chars() {
1509        if ch == '[' || ch == ']' {
1510            out.push('\\');
1511        }
1512        out.push(ch);
1513    }
1514    out
1515}
1516
1517/// Escapes bare URLs (`http://` and `https://`) in plain text so they are not
1518/// parsed as `inlineCard` nodes during round-trip.  The leading `h` is
1519/// backslash-escaped, which is enough to prevent the auto-link detector from
1520/// matching the URL while the existing backslash-escape handler restores it on
1521/// re-parse.
1522fn escape_bare_urls(text: &str) -> String {
1523    let mut result = String::with_capacity(text.len());
1524    for (i, ch) in text.char_indices() {
1525        if ch == 'h' {
1526            let rest = &text[i..];
1527            if rest.starts_with("http://") || rest.starts_with("https://") {
1528                result.push('\\');
1529            }
1530        }
1531        result.push(ch);
1532    }
1533    result
1534}
1535
1536/// Returns `true` if the string can be embedded in a `:card[...]` (or similar
1537/// bracketed inline directive) without breaking the depth-based bracket matcher
1538/// used by [`try_parse_inline_directive`].
1539///
1540/// The parser scans the content enclosed in `[...]` treating `[` as +1 and `]`
1541/// as −1 on depth, closing when depth returns to zero.  A value is safe if
1542/// every prefix has `count('[') >= count(']') − 1` (i.e., the running depth
1543/// never dips below zero before the end) and it contains no newline.
1544fn url_safe_in_bracket_content(s: &str) -> bool {
1545    if s.contains('\n') {
1546        return false;
1547    }
1548    let mut depth: i32 = 1;
1549    for ch in s.chars() {
1550        match ch {
1551            '[' => depth += 1,
1552            ']' => {
1553                depth -= 1;
1554                if depth == 0 {
1555                    return false;
1556                }
1557            }
1558            _ => {}
1559        }
1560    }
1561    true
1562}
1563
1564/// Escapes emoji shortcode patterns (`:name:`) in plain text so they are not
1565/// parsed as emoji nodes during round-trip.  Only the leading colon is
1566/// backslash-escaped, which is enough to prevent the parser from matching the
1567/// pattern while the existing backslash-escape handler restores it on re-parse.
1568///
1569/// The character class for the name segment must match `try_parse_emoji_shortcode`
1570/// exactly (Unicode `is_alphanumeric` plus `_`, `+`, `-`).  An ASCII-only escape
1571/// would leave Unicode patterns like `:Café:` or `:ZBC::Zendesk::配置:` un-escaped
1572/// while still being detected as emoji on re-parse, splitting the text node
1573/// (issue #552).
1574fn escape_emoji_shortcodes(text: &str) -> String {
1575    let mut result = String::with_capacity(text.len());
1576
1577    for (i, ch) in text.char_indices() {
1578        if ch == ':' {
1579            // Check if this is a `:name:` pattern where name matches the
1580            // same character class accepted by `try_parse_emoji_shortcode`.
1581            let after = i + 1;
1582            if after < text.len() {
1583                let name_end = text[after..]
1584                    .find(|c: char| !c.is_alphanumeric() && c != '_' && c != '+' && c != '-')
1585                    .map_or(text[after..].len(), |pos| pos);
1586                if name_end > 0
1587                    && after + name_end < text.len()
1588                    && text.as_bytes()[after + name_end] == b':'
1589                {
1590                    // Found `:name:` pattern — escape the leading colon
1591                    result.push('\\');
1592                }
1593            }
1594        }
1595        result.push(ch);
1596    }
1597
1598    result
1599}
1600
1601/// Escapes a leading list-marker pattern on a line so it is not
1602/// re-parsed as a new list item.  `"2. text"` → `"2\. text"`,
1603/// `"- text"` → `"\- text"`.
1604fn escape_list_marker(line: &str) -> String {
1605    if let Some(dot_pos) = line.find(". ") {
1606        if parse_ordered_list_marker(line).is_some() {
1607            let mut s = String::with_capacity(line.len() + 1);
1608            s.push_str(&line[..dot_pos]);
1609            s.push('\\');
1610            s.push_str(&line[dot_pos..]);
1611            return s;
1612        }
1613    }
1614    for prefix in &["- ", "* ", "+ "] {
1615        if line.starts_with(prefix) {
1616            let mut s = String::with_capacity(line.len() + 1);
1617            s.push('\\');
1618            s.push_str(line);
1619            return s;
1620        }
1621    }
1622    line.to_string()
1623}
1624
1625/// Checks if a line is a valid fenced code block opener.
1626///
1627/// Per CommonMark: the opener is a sequence of three or more backticks
1628/// followed by an info string that must not contain any backtick
1629/// character, otherwise some inline code spans would be misinterpreted
1630/// as the beginning of a fenced code block.
1631fn is_code_fence_opener(line: &str) -> bool {
1632    if !line.starts_with("```") {
1633        return false;
1634    }
1635    !line[3..].contains('`')
1636}
1637
1638/// Checks if a line is a horizontal rule.
1639fn is_horizontal_rule(line: &str) -> bool {
1640    let trimmed = line.trim();
1641    trimmed.len() >= 3
1642        && ((trimmed.starts_with("---") && trimmed.chars().all(|c| c == '-'))
1643            || (trimmed.starts_with("***") && trimmed.chars().all(|c| c == '*'))
1644            || (trimmed.starts_with("___") && trimmed.chars().all(|c| c == '_')))
1645}
1646
1647/// Checks if a line is a GFM table separator (e.g., "|---|---|").
1648fn is_table_separator(line: &str) -> bool {
1649    let trimmed = line.trim();
1650    trimmed.contains('|')
1651        && trimmed
1652            .chars()
1653            .all(|c| c == '|' || c == '-' || c == ':' || c == ' ')
1654}
1655
1656/// Parses a GFM table row into cell contents.
1657///
1658/// Splits on unescaped `|` characters; a preceding backslash (`\|`) is
1659/// interpreted as a literal pipe and unescaped in the emitted cell content
1660/// (see issue #579).  This allows code spans and other inline content that
1661/// contain literal `|` to survive round-trip through a pipe table.
1662fn parse_table_row(line: &str) -> Vec<String> {
1663    let trimmed = line.trim();
1664    let trimmed = trimmed.strip_prefix('|').unwrap_or(trimmed);
1665    let trimmed = trimmed.strip_suffix('|').unwrap_or(trimmed);
1666
1667    let mut cells: Vec<String> = Vec::new();
1668    let mut current = String::new();
1669    let mut chars = trimmed.chars().peekable();
1670    while let Some(ch) = chars.next() {
1671        if ch == '\\' && chars.peek() == Some(&'|') {
1672            current.push('|');
1673            chars.next();
1674        } else if ch == '|' {
1675            cells.push(std::mem::take(&mut current));
1676        } else {
1677            current.push(ch);
1678        }
1679    }
1680    cells.push(current);
1681
1682    cells
1683        .iter()
1684        .map(|s| {
1685            // Strip exactly one leading and one trailing space (pipe table padding).
1686            // Preserve any additional whitespace as significant content.
1687            let stripped = s.strip_prefix(' ').unwrap_or(s.as_str());
1688            let stripped = stripped.strip_suffix(' ').unwrap_or(stripped);
1689            stripped.to_string()
1690        })
1691        .collect()
1692}
1693
1694/// Parses column alignments from a GFM table separator row.
1695/// Returns a vec of `Option<&str>` where `Some("center")` or `Some("end")` indicate alignment.
1696fn parse_table_alignments(separator_line: &str) -> Vec<Option<&'static str>> {
1697    let trimmed = separator_line.trim();
1698    let trimmed = trimmed.strip_prefix('|').unwrap_or(trimmed);
1699    let trimmed = trimmed.strip_suffix('|').unwrap_or(trimmed);
1700
1701    trimmed
1702        .split('|')
1703        .map(|cell| {
1704            let cell = cell.trim();
1705            let starts_colon = cell.starts_with(':');
1706            let ends_colon = cell.ends_with(':');
1707            match (starts_colon, ends_colon) {
1708                (true, true) => Some("center"),
1709                (false, true) => Some("end"),
1710                _ => None, // left/default
1711            }
1712        })
1713        .collect()
1714}
1715
1716/// Applies an alignment mark to a paragraph node if alignment is specified.
1717fn apply_column_alignment(para: &mut AdfNode, alignment: Option<&str>) {
1718    if let Some(align) = alignment {
1719        para.marks = Some(vec![AdfMark::alignment(align)]);
1720    }
1721}
1722
1723/// Extracts `{attrs}` prefix from a pipe table cell text.
1724/// Returns `(remaining_text, Option<adf_attrs_json>)`.
1725fn extract_cell_attrs(cell_text: &str) -> (String, Option<serde_json::Value>) {
1726    let trimmed = cell_text.trim_start();
1727    if !trimmed.starts_with('{') {
1728        return (cell_text.to_string(), None);
1729    }
1730    if let Some((end_pos, attrs)) = parse_attrs(trimmed, 0) {
1731        let remaining = trimmed[end_pos..].trim_start().to_string();
1732        let adf_attrs = build_cell_attrs(&attrs);
1733        (remaining, Some(adf_attrs))
1734    } else {
1735        (cell_text.to_string(), None)
1736    }
1737}
1738
1739/// Tries to parse a line as a block-level image and return a mediaSingle ADF node.
1740/// Used by both `try_image` (top-level blocks) and list item parsing.
1741fn try_parse_media_single_from_line(line: &str) -> Option<AdfNode> {
1742    let line = line.trim();
1743    if !line.starts_with("![") {
1744        return None;
1745    }
1746
1747    let (alt, url) = parse_image_syntax(line)?;
1748    let alt_opt = if alt.is_empty() { None } else { Some(alt) };
1749
1750    let paren_open = line.find("](")? + 1; // index of '('
1751    let img_end = find_closing_paren(line, paren_open)? + 1;
1752    let after_img = line[img_end..].trim_start();
1753
1754    if after_img.starts_with('{') {
1755        if let Some((_, attrs)) = parse_attrs(after_img, 0) {
1756            // Confluence file attachment — reconstruct type:file media node
1757            if attrs.get("type") == Some("file") || attrs.get("id").is_some() {
1758                let mut media_attrs = serde_json::json!({"type": "file"});
1759                if let Some(id) = attrs.get("id") {
1760                    media_attrs["id"] = serde_json::Value::String(id.to_string());
1761                }
1762                if let Some(collection) = attrs.get("collection") {
1763                    media_attrs["collection"] = serde_json::Value::String(collection.to_string());
1764                }
1765                if let Some(occurrence_key) = attrs.get("occurrenceKey") {
1766                    media_attrs["occurrenceKey"] =
1767                        serde_json::Value::String(occurrence_key.to_string());
1768                }
1769                if let Some(height) = attrs.get("height") {
1770                    if let Some(h) = parse_numeric_attr(height) {
1771                        media_attrs["height"] = h;
1772                    }
1773                }
1774                if let Some(width) = attrs.get("width") {
1775                    if let Some(w) = parse_numeric_attr(width) {
1776                        media_attrs["width"] = w;
1777                    }
1778                }
1779                if let Some(alt_text) = alt_opt {
1780                    media_attrs["alt"] = serde_json::Value::String(alt_text.to_string());
1781                }
1782                if let Some(local_id) = attrs.get("localId") {
1783                    media_attrs["localId"] = serde_json::Value::String(local_id.to_string());
1784                }
1785                let mut ms_attrs = serde_json::json!({"layout": "center"});
1786                if let Some(layout) = attrs.get("layout") {
1787                    ms_attrs["layout"] = serde_json::Value::String(layout.to_string());
1788                }
1789                if let Some(ms_width) = attrs.get("mediaWidth") {
1790                    if let Some(w) = parse_numeric_attr(ms_width) {
1791                        ms_attrs["width"] = w;
1792                    }
1793                }
1794                if let Some(wt) = attrs.get("widthType") {
1795                    ms_attrs["widthType"] = serde_json::Value::String(wt.to_string());
1796                }
1797                if let Some(mode) = attrs.get("mode") {
1798                    ms_attrs["mode"] = serde_json::Value::String(mode.to_string());
1799                }
1800                let border_marks = build_border_marks(&attrs);
1801                let media_marks = if border_marks.is_empty() {
1802                    None
1803                } else {
1804                    Some(border_marks)
1805                };
1806                return Some(AdfNode {
1807                    node_type: "mediaSingle".to_string(),
1808                    attrs: Some(ms_attrs),
1809                    content: Some(vec![AdfNode {
1810                        node_type: "media".to_string(),
1811                        attrs: Some(media_attrs),
1812                        content: None,
1813                        text: None,
1814                        marks: media_marks,
1815                        local_id: None,
1816                        parameters: None,
1817                    }]),
1818                    text: None,
1819                    marks: None,
1820                    local_id: None,
1821                    parameters: None,
1822                });
1823            }
1824
1825            // External image — apply layout/width/widthType to mediaSingle attrs
1826            let mut node = AdfNode::media_single(url, alt_opt);
1827            if let Some(ref mut node_attrs) = node.attrs {
1828                if let Some(layout) = attrs.get("layout") {
1829                    node_attrs["layout"] = serde_json::Value::String(layout.to_string());
1830                }
1831                if let Some(width) = attrs.get("width") {
1832                    if let Some(w) = parse_numeric_attr(width) {
1833                        node_attrs["width"] = w;
1834                    }
1835                }
1836                if let Some(wt) = attrs.get("widthType") {
1837                    node_attrs["widthType"] = serde_json::Value::String(wt.to_string());
1838                }
1839                if let Some(mode) = attrs.get("mode") {
1840                    node_attrs["mode"] = serde_json::Value::String(mode.to_string());
1841                }
1842            }
1843            if let Some(ref mut content) = node.content {
1844                if let Some(media) = content.first_mut() {
1845                    if let Some(local_id) = attrs.get("localId") {
1846                        if let Some(ref mut media_attrs) = media.attrs {
1847                            media_attrs["localId"] =
1848                                serde_json::Value::String(local_id.to_string());
1849                        }
1850                    }
1851                    let border_marks = build_border_marks(&attrs);
1852                    if !border_marks.is_empty() {
1853                        media.marks = Some(border_marks);
1854                    }
1855                }
1856            }
1857            return Some(node);
1858        }
1859    }
1860
1861    Some(AdfNode::media_single(url, alt_opt))
1862}
1863
1864/// Parses `![alt](url)` image syntax.
1865fn parse_image_syntax(line: &str) -> Option<(&str, &str)> {
1866    let line = line.trim();
1867    if !line.starts_with("![") {
1868        return None;
1869    }
1870
1871    let alt_end = line.find("](")?;
1872    let alt = &line[2..alt_end];
1873    let paren_start = alt_end + 1; // index of the '('
1874    let url_end = find_closing_paren(line, paren_start)?;
1875    let url = &line[paren_start + 1..url_end];
1876
1877    Some((alt, url))
1878}
1879
1880// ── Inline Parsing ──────────────────────────────────────────────────
1881
1882/// Parses inline markdown content into ADF inline nodes.
1883///
1884/// Detects bare URLs (e.g., `https://example.com`) and promotes them to
1885/// `inlineCard` nodes. Call this at the top level (paragraph, heading, cell,
1886/// list item) where a bare URL represents a smart link.
1887fn parse_inline(text: &str) -> Vec<AdfNode> {
1888    parse_inline_impl(text, true)
1889}
1890
1891/// Parses inline markdown content without promoting bare URLs to `inlineCard`.
1892///
1893/// Used when recursing into mark-wrapping constructs such as emphasis, strike,
1894/// bracketed spans, or links.  In those contexts, the enclosing syntax already
1895/// declares the semantic role of the content — a URL inside `[url]{underline}`
1896/// or `**url**` is the user's text, not a smart link (issue #553).
1897fn parse_inline_no_auto_cards(text: &str) -> Vec<AdfNode> {
1898    parse_inline_impl(text, false)
1899}
1900
1901/// Implementation backing [`parse_inline`] and [`parse_inline_no_auto_cards`].
1902///
1903/// When `auto_inline_card` is `false`, bare `http://`/`https://` URLs are
1904/// treated as plain text instead of being promoted to `inlineCard` nodes.
1905fn parse_inline_impl(text: &str, auto_inline_card: bool) -> Vec<AdfNode> {
1906    let mut nodes = Vec::new();
1907    let mut chars = text.char_indices().peekable();
1908    let mut plain_start = 0;
1909
1910    while let Some(&(i, ch)) = chars.peek() {
1911        match ch {
1912            '*' | '_' => {
1913                if let Some((end, content, is_bold)) = try_parse_emphasis(text, i) {
1914                    flush_plain(text, plain_start, i, &mut nodes);
1915                    let mark = if is_bold {
1916                        AdfMark::strong()
1917                    } else {
1918                        AdfMark::em()
1919                    };
1920                    let inner = parse_inline_no_auto_cards(content);
1921                    for mut node in inner {
1922                        prepend_mark(&mut node, mark.clone());
1923                        nodes.push(node);
1924                    }
1925                    // Advance past the consumed characters
1926                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
1927                        chars.next();
1928                    }
1929                    plain_start = end;
1930                    continue;
1931                }
1932                // For underscores, skip the entire delimiter run so that
1933                // individual `_` chars within a `__` or `___` run are not
1934                // re-tried as separate emphasis openers (CommonMark treats
1935                // consecutive underscores as a single delimiter run).
1936                if ch == '_' {
1937                    while chars.peek().is_some_and(|&(_, c)| c == '_') {
1938                        chars.next();
1939                    }
1940                } else {
1941                    chars.next();
1942                }
1943            }
1944            '~' => {
1945                if let Some((end, content)) = try_parse_strikethrough(text, i) {
1946                    flush_plain(text, plain_start, i, &mut nodes);
1947                    let inner = parse_inline_no_auto_cards(content);
1948                    for mut node in inner {
1949                        prepend_mark(&mut node, AdfMark::strike());
1950                        nodes.push(node);
1951                    }
1952                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
1953                        chars.next();
1954                    }
1955                    plain_start = end;
1956                    continue;
1957                }
1958                chars.next();
1959            }
1960            '`' => {
1961                if let Some((end, content)) = try_parse_inline_code(text, i) {
1962                    flush_plain(text, plain_start, i, &mut nodes);
1963                    nodes.push(AdfNode::text_with_marks(content, vec![AdfMark::code()]));
1964                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
1965                        chars.next();
1966                    }
1967                    plain_start = end;
1968                    continue;
1969                }
1970                // No code span starting here; skip past the entire backtick
1971                // run so a longer opening run isn't retried as a shorter one.
1972                while chars.peek().is_some_and(|&(_, c)| c == '`') {
1973                    chars.next();
1974                }
1975            }
1976            '[' => {
1977                if let Some((end, link_text, href)) = try_parse_link(text, i) {
1978                    flush_plain(text, plain_start, i, &mut nodes);
1979                    if link_text.starts_with("http://") || link_text.starts_with("https://") {
1980                        // URL-as-link-text: emit as text with link mark,
1981                        // not via parse_inline which would produce an inlineCard.
1982                        // Covers both exact matches and trailing-slash mismatches
1983                        // (issue #523).
1984                        nodes.push(AdfNode::text_with_marks(
1985                            link_text,
1986                            vec![AdfMark::link(href)],
1987                        ));
1988                    } else {
1989                        let inner = parse_inline_no_auto_cards(link_text);
1990                        for mut node in inner {
1991                            prepend_mark(&mut node, AdfMark::link(href));
1992                            nodes.push(node);
1993                        }
1994                    }
1995                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
1996                        chars.next();
1997                    }
1998                    plain_start = end;
1999                    continue;
2000                }
2001                // Try bracketed span with attributes: [text]{underline}
2002                if let Some((end, span_nodes)) = try_parse_bracketed_span(text, i) {
2003                    flush_plain(text, plain_start, i, &mut nodes);
2004                    nodes.extend(span_nodes);
2005                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2006                        chars.next();
2007                    }
2008                    plain_start = end;
2009                    continue;
2010                }
2011                chars.next();
2012            }
2013            ':' => {
2014                // Try generic inline directive (:card[url], :status[text]{attrs}, etc.)
2015                if let Some(node) = try_dispatch_inline_directive(text, i) {
2016                    flush_plain(text, plain_start, i, &mut nodes);
2017                    let end = node.1;
2018                    nodes.push(node.0);
2019                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2020                        chars.next();
2021                    }
2022                    plain_start = end;
2023                    continue;
2024                }
2025                // Try emoji shortcode :name: with optional {attrs}
2026                if let Some((end, short_name)) = try_parse_emoji_shortcode(text, i) {
2027                    flush_plain(text, plain_start, i, &mut nodes);
2028                    let (final_end, emoji_node) = parse_emoji_with_attrs(text, end, short_name);
2029                    nodes.push(emoji_node);
2030                    while chars.peek().is_some_and(|&(idx, _)| idx < final_end) {
2031                        chars.next();
2032                    }
2033                    plain_start = final_end;
2034                    continue;
2035                }
2036                chars.next();
2037            }
2038            ' ' if text[i..].starts_with("  \n") => {
2039                // Trailing-space line break → hardBreak node.
2040                // Flush preceding text (without the trailing spaces).
2041                flush_plain(text, plain_start, i, &mut nodes);
2042                nodes.push(AdfNode::hard_break());
2043                // Skip past all spaces and the newline
2044                while chars.peek().is_some_and(|&(_, c)| c == ' ') {
2045                    chars.next();
2046                }
2047                // Skip the newline
2048                if chars.peek().is_some_and(|&(_, c)| c == '\n') {
2049                    chars.next();
2050                }
2051                plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2052            }
2053            '!' if text[i..].starts_with("![") => {
2054                // Inline image — skip the ! and let [ handle it next iteration
2055                // (Images at block level are handled by try_image; inline images
2056                // degrade to link text in ADF since inline media is complex)
2057                chars.next();
2058            }
2059            'h' if auto_inline_card
2060                && (text[i..].starts_with("http://") || text[i..].starts_with("https://")) =>
2061            {
2062                if let Some((end, url)) = try_parse_bare_url(text, i) {
2063                    flush_plain(text, plain_start, i, &mut nodes);
2064                    nodes.push(AdfNode::inline_card(url));
2065                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2066                        chars.next();
2067                    }
2068                    plain_start = end;
2069                    continue;
2070                }
2071                chars.next();
2072            }
2073            '\\' if text.as_bytes().get(i + 1) == Some(&b'n')
2074                && text.as_bytes().get(i + 2) != Some(&b'\n') =>
2075            {
2076                // Issue #454: `\n` (backslash + letter n) encodes a literal
2077                // newline inside a text node. Emit the newline as a separate
2078                // text node so merge_adjacent_text can reassemble it.
2079                flush_plain(text, plain_start, i, &mut nodes);
2080                nodes.push(AdfNode::text("\n"));
2081                chars.next(); // consume the '\'
2082                chars.next(); // consume the 'n'
2083                plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2084            }
2085            '\\' if i + 1 < text.len() && !text[i..].starts_with("\\\n") => {
2086                // Backslash escape: skip the backslash and treat the next
2087                // character as literal text (e.g. `\\` → `\`,
2088                // `2\. text` → `2. text`, `\*word\*` → `*word*` without
2089                // emphasis, `\:fire:` → `:fire:` without emoji parsing).
2090                flush_plain(text, plain_start, i, &mut nodes);
2091                chars.next(); // consume the backslash
2092                              // Set plain_start to the escaped character so it is included
2093                              // in the next plain-text run, then advance past it so it is
2094                              // not re-interpreted as a special character (e.g. `*`, `_`, `:`).
2095                plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2096                chars.next(); // consume the escaped character
2097            }
2098            '\\' if text[i..].starts_with("\\\n") => {
2099                // Backslash line break → hardBreak node.
2100                flush_plain(text, plain_start, i, &mut nodes);
2101                nodes.push(AdfNode::hard_break());
2102                chars.next(); // consume the '\'
2103                              // Skip the newline
2104                if chars.peek().is_some_and(|&(_, c)| c == '\n') {
2105                    chars.next();
2106                }
2107                plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2108            }
2109            '\\' if i + 1 == text.len() => {
2110                // Trailing backslash at end of paragraph text → hardBreak node.
2111                flush_plain(text, plain_start, i, &mut nodes);
2112                nodes.push(AdfNode::hard_break());
2113                chars.next(); // consume the '\'
2114                plain_start = text.len();
2115            }
2116            _ => {
2117                chars.next();
2118            }
2119        }
2120    }
2121
2122    // Flush remaining plain text
2123    if plain_start < text.len() {
2124        let remaining = &text[plain_start..];
2125        if !remaining.is_empty() {
2126            nodes.push(AdfNode::text(remaining));
2127        }
2128    }
2129
2130    // Merge adjacent unmarked text nodes that can arise from backslash
2131    // escape handling (e.g. `"2"` + `". text"` → `"2. text"`).
2132    merge_adjacent_text(&mut nodes);
2133
2134    nodes
2135}
2136
2137/// Merges consecutive unmarked text nodes in-place.
2138fn merge_adjacent_text(nodes: &mut Vec<AdfNode>) {
2139    let mut i = 0;
2140    while i + 1 < nodes.len() {
2141        if nodes[i].node_type == "text"
2142            && nodes[i + 1].node_type == "text"
2143            && nodes[i].marks.is_none()
2144            && nodes[i + 1].marks.is_none()
2145        {
2146            let next_text = nodes[i + 1].text.clone().unwrap_or_default();
2147            if let Some(ref mut t) = nodes[i].text {
2148                t.push_str(&next_text);
2149            }
2150            nodes.remove(i + 1);
2151        } else {
2152            i += 1;
2153        }
2154    }
2155}
2156
2157/// Flushes accumulated plain text as a text node.
2158fn flush_plain(text: &str, start: usize, end: usize, nodes: &mut Vec<AdfNode>) {
2159    if start < end {
2160        let plain = &text[start..end];
2161        if !plain.is_empty() {
2162            nodes.push(AdfNode::text(plain));
2163        }
2164    }
2165}
2166
2167/// Adds a mark to a node (creates marks vec if needed).
2168#[cfg(test)]
2169fn add_mark(node: &mut AdfNode, mark: AdfMark) {
2170    if let Some(ref mut marks) = node.marks {
2171        marks.push(mark);
2172    } else {
2173        node.marks = Some(vec![mark]);
2174    }
2175}
2176
2177/// Prepends a mark before existing marks to preserve outside-in ordering.
2178fn prepend_mark(node: &mut AdfNode, mark: AdfMark) {
2179    if let Some(ref mut marks) = node.marks {
2180        marks.insert(0, mark);
2181    } else {
2182        node.marks = Some(vec![mark]);
2183    }
2184}
2185
2186/// Returns `true` when an underscore delimiter run of `len` bytes starting at
2187/// byte position `delim_pos` in `text` is flanked by alphanumeric characters on
2188/// **both** sides — meaning it sits inside a word and must NOT open or close an
2189/// emphasis span per CommonMark.
2190fn is_intraword_underscore(text: &str, delim_pos: usize, len: usize) -> bool {
2191    let before = text[..delim_pos]
2192        .chars()
2193        .next_back()
2194        .is_some_and(char::is_alphanumeric);
2195    let after = text[delim_pos + len..]
2196        .chars()
2197        .next()
2198        .is_some_and(char::is_alphanumeric);
2199    before && after
2200}
2201
2202/// Finds the first occurrence of `needle` in `haystack`, skipping over
2203/// backslash-escaped characters (e.g. `\*` is not matched when searching
2204/// for `*`).
2205fn find_unescaped(haystack: &str, needle: &str) -> Option<usize> {
2206    let needle_bytes = needle.as_bytes();
2207    let hay_bytes = haystack.as_bytes();
2208    let mut i = 0;
2209    while i < hay_bytes.len() {
2210        if hay_bytes[i] == b'\\' {
2211            i += 2; // skip escaped character
2212            continue;
2213        }
2214        if hay_bytes[i..].starts_with(needle_bytes) {
2215            return Some(i);
2216        }
2217        i += 1;
2218    }
2219    None
2220}
2221
2222/// Finds the first occurrence of a single byte `ch` in `haystack`, skipping
2223/// over backslash-escaped characters.
2224fn find_unescaped_char(haystack: &str, ch: u8) -> Option<usize> {
2225    let hay_bytes = haystack.as_bytes();
2226    let mut i = 0;
2227    while i < hay_bytes.len() {
2228        if hay_bytes[i] == b'\\' {
2229            i += 2;
2230            continue;
2231        }
2232        if hay_bytes[i] == ch {
2233            return Some(i);
2234        }
2235        i += 1;
2236    }
2237    None
2238}
2239
2240/// Tries to parse ***bold+italic***, **bold**, *italic* (or underscore variants) starting at position `i`.
2241/// Returns (end_position, inner_content, is_bold).
2242///
2243/// The triple-delimiter case (`***` / `___`) is checked first so that `***text***` is parsed as
2244/// bold wrapping italic content, rather than having the `**` branch consume the wrong closing
2245/// delimiter and leave stray `*` characters in the text (see issue #401).
2246///
2247/// For underscore delimiters, intraword positions are rejected per CommonMark: a `_` flanked
2248/// by alphanumeric characters on both sides must not open or close emphasis (see issue #438).
2249fn try_parse_emphasis(text: &str, i: usize) -> Option<(usize, &str, bool)> {
2250    let rest = &text[i..];
2251
2252    // Bold+italic: *** or ___
2253    // Parse as bold wrapping italic: the inner content will be recursively parsed and pick up
2254    // the inner * / _ as an em mark.
2255    if rest.starts_with("***") || rest.starts_with("___") {
2256        let is_underscore = rest.starts_with("___");
2257        if is_underscore && is_intraword_underscore(text, i, 3) {
2258            return None;
2259        }
2260        let triple = &rest[..3];
2261        let after = &rest[3..];
2262        if let Some(close) = find_unescaped(after, triple) {
2263            if close > 0 {
2264                let close_pos = i + 3 + close;
2265                if is_underscore && is_intraword_underscore(text, close_pos, 3) {
2266                    return None;
2267                }
2268                // Return a slice that includes the inner italic delimiters from the
2269                // original text: for `***text***`, return `*text*`.  The recursive
2270                // parse_inline call will then pick up the inner `*…*` as an em mark.
2271                let content = &rest[2..=3 + close];
2272                let end = i + 3 + close + 3;
2273                return Some((end, content, true));
2274            }
2275        }
2276    }
2277
2278    // Bold: ** or __
2279    if rest.starts_with("**") || rest.starts_with("__") {
2280        let is_underscore = rest.starts_with("__");
2281        if is_underscore && is_intraword_underscore(text, i, 2) {
2282            return None;
2283        }
2284        let delimiter = &rest[..2];
2285        let after = &rest[2..];
2286        let close = find_unescaped(after, delimiter)?;
2287        if close == 0 {
2288            return None;
2289        }
2290        let close_pos = i + 2 + close;
2291        if is_underscore && is_intraword_underscore(text, close_pos, 2) {
2292            return None;
2293        }
2294        let content = &after[..close];
2295        let end = i + 2 + close + 2;
2296        return Some((end, content, true));
2297    }
2298
2299    // Italic: * or _
2300    if rest.starts_with('*') || rest.starts_with('_') {
2301        let delim_char = rest.as_bytes()[0];
2302        let is_underscore = delim_char == b'_';
2303        if is_underscore && is_intraword_underscore(text, i, 1) {
2304            return None;
2305        }
2306        let after = &rest[1..];
2307        let close = find_unescaped_char(after, delim_char)?;
2308        if close == 0 {
2309            return None;
2310        }
2311        let close_pos = i + 1 + close;
2312        if is_underscore && is_intraword_underscore(text, close_pos, 1) {
2313            return None;
2314        }
2315        let content = &after[..close];
2316        let end = i + 1 + close + 1;
2317        return Some((end, content, false));
2318    }
2319
2320    None
2321}
2322
2323/// Tries to parse ~~strikethrough~~ starting at position `i`.
2324fn try_parse_strikethrough(text: &str, i: usize) -> Option<(usize, &str)> {
2325    let rest = &text[i..];
2326    if !rest.starts_with("~~") {
2327        return None;
2328    }
2329    let after = &rest[2..];
2330    let close = after.find("~~")?;
2331    if close == 0 {
2332        return None;
2333    }
2334    let content = &after[..close];
2335    Some((i + 2 + close + 2, content))
2336}
2337
2338/// Tries to parse a CommonMark inline code span starting at position `i`.
2339///
2340/// Supports multi-backtick delimiters: the opening run of N backticks must
2341/// be matched by a closing run of exactly N backticks.  If both ends of the
2342/// enclosed content begin and end with a space and the content is not all
2343/// spaces, one space is stripped from each side per the CommonMark spec.
2344fn try_parse_inline_code(text: &str, i: usize) -> Option<(usize, &str)> {
2345    let rest = &text[i..];
2346    let bytes = rest.as_bytes();
2347    if bytes.is_empty() || bytes[0] != b'`' {
2348        return None;
2349    }
2350    let mut opening = 0usize;
2351    while opening < bytes.len() && bytes[opening] == b'`' {
2352        opening += 1;
2353    }
2354
2355    let mut j = opening;
2356    while j < bytes.len() {
2357        if bytes[j] == b'`' {
2358            let run_start = j;
2359            while j < bytes.len() && bytes[j] == b'`' {
2360                j += 1;
2361            }
2362            if j - run_start == opening {
2363                let content = &rest[opening..run_start];
2364                let content = strip_code_span_padding(content);
2365                return Some((i + j, content));
2366            }
2367        } else {
2368            j += 1;
2369        }
2370    }
2371    None
2372}
2373
2374/// Implements the CommonMark code-span space-stripping rule: if the content
2375/// both begins and ends with a space character and is not composed entirely
2376/// of spaces, one space character is removed from each side.
2377fn strip_code_span_padding(content: &str) -> &str {
2378    let bytes = content.as_bytes();
2379    if bytes.len() >= 2
2380        && bytes[0] == b' '
2381        && bytes[bytes.len() - 1] == b' '
2382        && content.bytes().any(|b| b != b' ')
2383    {
2384        &content[1..content.len() - 1]
2385    } else {
2386        content
2387    }
2388}
2389
2390/// Tries to parse a bracketed span `[text]{attrs}` starting at position `i`.
2391/// Used for `[text]{underline}` and similar constructs.
2392fn try_parse_bracketed_span(text: &str, i: usize) -> Option<(usize, Vec<AdfNode>)> {
2393    let rest = &text[i..];
2394    if !rest.starts_with('[') {
2395        return None;
2396    }
2397
2398    // Find the matching ] by counting bracket depth (supports nested brackets
2399    // such as [[text](url)]{underline} for underline-before-link ordering).
2400    // Backslash-escaped brackets are skipped (issue #493).
2401    let mut depth: usize = 0;
2402    let mut bracket_close = None;
2403    let bs_bytes = rest.as_bytes();
2404    for (j, ch) in rest.char_indices() {
2405        match ch {
2406            '\\' if j + 1 < bs_bytes.len()
2407                && (bs_bytes[j + 1] == b'[' || bs_bytes[j + 1] == b']') => {}
2408            '[' if j == 0 || bs_bytes[j - 1] != b'\\' => depth += 1,
2409            ']' if j == 0 || bs_bytes[j - 1] != b'\\' => {
2410                depth -= 1;
2411                if depth == 0 {
2412                    bracket_close = Some(j);
2413                    break;
2414                }
2415            }
2416            _ => {}
2417        }
2418    }
2419    let bracket_close = bracket_close?;
2420    // Make sure this isn't a link: next char after ] must be { not (
2421    let after_bracket = &rest[bracket_close + 1..];
2422    if !after_bracket.starts_with('{') {
2423        return None;
2424    }
2425
2426    let span_text = &rest[1..bracket_close];
2427    let attrs_start = i + bracket_close + 1;
2428    let (attrs_end, attrs) = parse_attrs(text, attrs_start)?;
2429
2430    let mut marks = Vec::new();
2431    if attrs.has_flag("underline") {
2432        marks.push(AdfMark::underline());
2433    }
2434    let ann_ids = attrs.get_all("annotation-id");
2435    let ann_types = attrs.get_all("annotation-type");
2436    for (idx, ann_id) in ann_ids.iter().enumerate() {
2437        let ann_type = ann_types.get(idx).copied().unwrap_or("inlineComment");
2438        marks.push(AdfMark::annotation(ann_id, ann_type));
2439    }
2440
2441    if marks.is_empty() {
2442        return None; // no recognized marks
2443    }
2444
2445    let inner = parse_inline_no_auto_cards(span_text);
2446    let result: Vec<AdfNode> = inner
2447        .into_iter()
2448        .map(|mut node| {
2449            // Prepend bracket marks before inner marks to preserve original
2450            // ADF mark ordering (e.g., [underline, strong] not [strong, underline]).
2451            let mut combined = marks.clone();
2452            if let Some(ref existing) = node.marks {
2453                combined.extend(existing.iter().cloned());
2454            }
2455            node.marks = Some(combined);
2456            node
2457        })
2458        .collect();
2459
2460    Some((attrs_end, result))
2461}
2462
2463/// Dispatches an inline directive to the appropriate ADF node constructor.
2464/// Returns `(AdfNode, end_pos)` on success.
2465fn try_dispatch_inline_directive(text: &str, pos: usize) -> Option<(AdfNode, usize)> {
2466    let d = try_parse_inline_directive(text, pos)?;
2467    let content = d.content.as_deref().unwrap_or("");
2468
2469    let node = match d.name.as_str() {
2470        "card" => {
2471            // Prefer the `url` attribute when present; fall back to the
2472            // bracketed content.  The attribute form is used when the URL
2473            // contains characters (such as `]` or `\n`) that would otherwise
2474            // break `:card[URL]` parsing.
2475            let url = d
2476                .attrs
2477                .as_ref()
2478                .and_then(|a| a.get("url"))
2479                .unwrap_or(content);
2480            let mut node = AdfNode::inline_card(url);
2481            pass_through_local_id(&d.attrs, &mut node);
2482            node
2483        }
2484        "status" => {
2485            let color = d
2486                .attrs
2487                .as_ref()
2488                .and_then(|a| a.get("color"))
2489                .unwrap_or("neutral");
2490            let mut node = AdfNode::status(content, color);
2491            // Pass through style and localId if present
2492            if let Some(ref attrs) = d.attrs {
2493                if let Some(ref mut node_attrs) = node.attrs {
2494                    if let Some(style) = attrs.get("style") {
2495                        node_attrs["style"] = serde_json::Value::String(style.to_string());
2496                    }
2497                    if let Some(local_id) = attrs.get("localId") {
2498                        node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
2499                    }
2500                }
2501            }
2502            node
2503        }
2504        "date" => {
2505            let timestamp = d
2506                .attrs
2507                .as_ref()
2508                .and_then(|a| a.get("timestamp"))
2509                .map_or_else(|| iso_date_to_epoch_ms(content), ToString::to_string);
2510            let mut node = AdfNode::date(&timestamp);
2511            pass_through_local_id(&d.attrs, &mut node);
2512            node
2513        }
2514        "mention" => {
2515            let id = d.attrs.as_ref().and_then(|a| a.get("id")).unwrap_or("");
2516            let mut node = AdfNode::mention(id, content);
2517            // Pass through optional userType and accessLevel
2518            if let Some(ref attrs) = d.attrs {
2519                if let (Some(ref mut node_attrs), true) = (
2520                    &mut node.attrs,
2521                    attrs.get("userType").is_some() || attrs.get("accessLevel").is_some(),
2522                ) {
2523                    if let Some(ut) = attrs.get("userType") {
2524                        node_attrs["userType"] = serde_json::Value::String(ut.to_string());
2525                    }
2526                    if let Some(al) = attrs.get("accessLevel") {
2527                        node_attrs["accessLevel"] = serde_json::Value::String(al.to_string());
2528                    }
2529                }
2530            }
2531            pass_through_local_id(&d.attrs, &mut node);
2532            node
2533        }
2534        "span" => {
2535            let mut marks = Vec::new();
2536            if let Some(ref attrs) = d.attrs {
2537                if let Some(color) = attrs.get("color") {
2538                    marks.push(AdfMark::text_color(color));
2539                }
2540                if let Some(bg) = attrs.get("bg") {
2541                    marks.push(AdfMark::background_color(bg));
2542                }
2543                if attrs.has_flag("sub") {
2544                    marks.push(AdfMark::subsup("sub"));
2545                }
2546                if attrs.has_flag("sup") {
2547                    marks.push(AdfMark::subsup("sup"));
2548                }
2549            }
2550            if marks.is_empty() {
2551                AdfNode::text(content)
2552            } else {
2553                // Parse inner content to handle nested syntax (e.g., links).
2554                // Prepend span marks before inner marks to preserve ordering.
2555                let inner = parse_inline_no_auto_cards(content);
2556                let mut nodes: Vec<AdfNode> = inner
2557                    .into_iter()
2558                    .map(|mut node| {
2559                        let mut combined = marks.clone();
2560                        if let Some(ref existing) = node.marks {
2561                            combined.extend(existing.iter().cloned());
2562                        }
2563                        node.marks = Some(combined);
2564                        node
2565                    })
2566                    .collect();
2567                // Return the first marked node (typical case is a single node).
2568                nodes.remove(0)
2569            }
2570        }
2571        "placeholder" => AdfNode::placeholder(content),
2572        "media-inline" => {
2573            let mut json_attrs = serde_json::Map::new();
2574            if let Some(ref attrs) = d.attrs {
2575                for key in &["type", "id", "collection", "url", "alt", "width", "height"] {
2576                    if let Some(val) = attrs.get(key) {
2577                        if *key == "width" || *key == "height" {
2578                            if let Ok(n) = val.parse::<u64>() {
2579                                json_attrs.insert(
2580                                    (*key).to_string(),
2581                                    serde_json::Value::Number(n.into()),
2582                                );
2583                                continue;
2584                            }
2585                        }
2586                        json_attrs.insert(
2587                            (*key).to_string(),
2588                            serde_json::Value::String(val.to_string()),
2589                        );
2590                    }
2591                }
2592                if let Some(local_id) = attrs.get("localId") {
2593                    json_attrs.insert(
2594                        "localId".to_string(),
2595                        serde_json::Value::String(local_id.to_string()),
2596                    );
2597                }
2598            }
2599            AdfNode::media_inline(serde_json::Value::Object(json_attrs))
2600        }
2601        "extension" => {
2602            let ext_type = d.attrs.as_ref().and_then(|a| a.get("type")).unwrap_or("");
2603            let ext_key = d.attrs.as_ref().and_then(|a| a.get("key")).unwrap_or("");
2604            AdfNode::inline_extension(ext_type, ext_key, Some(content))
2605        }
2606        _ => return None, // unknown directive — fall through to plain text
2607    };
2608
2609    Some((node, d.end_pos))
2610}
2611
2612/// Tries to parse a bare URL (`http://` or `https://`) starting at position `i`.
2613/// Scans forward until whitespace, `)`, `]`, or end of string.
2614fn try_parse_bare_url(text: &str, i: usize) -> Option<(usize, &str)> {
2615    let rest = &text[i..];
2616    if !rest.starts_with("http://") && !rest.starts_with("https://") {
2617        return None;
2618    }
2619    // URL extends to the next whitespace or delimiter
2620    let end = rest
2621        .find(|c: char| c.is_whitespace() || c == ')' || c == ']' || c == '>')
2622        .unwrap_or(rest.len());
2623    // Strip trailing punctuation that's likely not part of the URL
2624    let url = rest[..end].trim_end_matches(['.', ',', ';', '!', '?']);
2625    if url.len() <= "https://".len() {
2626        return None; // too short to be a real URL
2627    }
2628    Some((i + url.len(), url))
2629}
2630
2631/// Tries to parse an emoji shortcode `:name:` starting at position `i`.
2632/// The name must match `[a-zA-Z0-9_+-]+`.
2633fn try_parse_emoji_shortcode(text: &str, i: usize) -> Option<(usize, &str)> {
2634    let rest = &text[i..];
2635    if !rest.starts_with(':') {
2636        return None;
2637    }
2638    let after = &rest[1..];
2639    let name_end =
2640        after.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '+' && c != '-')?;
2641    if name_end == 0 {
2642        return None;
2643    }
2644    if after.as_bytes().get(name_end) != Some(&b':') {
2645        return None;
2646    }
2647    let name = &after[..name_end];
2648    Some((i + 1 + name_end + 1, name))
2649}
2650
2651/// Parses an emoji shortcode that has already been matched, then checks for
2652/// trailing `{id="..." text="..."}` attributes to preserve round-trip fidelity.
2653fn parse_emoji_with_attrs(text: &str, shortcode_end: usize, short_name: &str) -> (usize, AdfNode) {
2654    // Issue #576: An emoji with a combined shortName like `:slightly_smiling_face::bow:`
2655    // is emitted as `:slightly_smiling_face::bow:{shortName="..." ...}`. Extend the
2656    // match through any adjacent `:name:` shortcodes so that a trailing directive
2657    // attaches to the whole chain as a single emoji, using the directive's shortName.
2658    let mut chain_end = shortcode_end;
2659    while let Some((next_end, _)) = try_parse_emoji_shortcode(text, chain_end) {
2660        chain_end = next_end;
2661    }
2662    if chain_end > shortcode_end {
2663        if let Some((attr_end, attrs)) = parse_attrs(text, chain_end) {
2664            return (attr_end, build_emoji_node(&attrs, short_name));
2665        }
2666    }
2667
2668    if let Some((attr_end, attrs)) = parse_attrs(text, shortcode_end) {
2669        (attr_end, build_emoji_node(&attrs, short_name))
2670    } else {
2671        (shortcode_end, AdfNode::emoji(&format!(":{short_name}:")))
2672    }
2673}
2674
2675/// Builds an emoji `AdfNode` from parsed directive attrs, falling back to
2676/// the matched shortcode name when `shortName` is absent from the directive.
2677fn build_emoji_node(attrs: &Attrs, short_name: &str) -> AdfNode {
2678    let resolved_name = attrs
2679        .get("shortName")
2680        .map_or_else(|| format!(":{short_name}:"), str::to_string);
2681    let mut emoji_attrs = serde_json::json!({ "shortName": resolved_name });
2682    if let Some(id) = attrs.get("id") {
2683        emoji_attrs["id"] = serde_json::Value::String(id.to_string());
2684    }
2685    if let Some(t) = attrs.get("text") {
2686        emoji_attrs["text"] = serde_json::Value::String(t.to_string());
2687    }
2688    if let Some(lid) = attrs.get("localId") {
2689        emoji_attrs["localId"] = serde_json::Value::String(lid.to_string());
2690    }
2691    AdfNode {
2692        node_type: "emoji".to_string(),
2693        attrs: Some(emoji_attrs),
2694        content: None,
2695        text: None,
2696        marks: None,
2697        local_id: None,
2698        parameters: None,
2699    }
2700}
2701
2702/// Finds the closing `)` that matches the opening `(` at position `open`,
2703/// counting nested parentheses so that URLs containing `(` and `)` are
2704/// handled correctly.  Returns the index of the matching `)` relative to
2705/// the start of `s`, or `None` if no match is found.
2706fn find_closing_paren(s: &str, open: usize) -> Option<usize> {
2707    let mut depth: usize = 0;
2708    for (j, ch) in s[open..].char_indices() {
2709        match ch {
2710            '(' => depth += 1,
2711            ')' => {
2712                depth -= 1;
2713                if depth == 0 {
2714                    return Some(open + j);
2715                }
2716            }
2717            _ => {}
2718        }
2719    }
2720    None
2721}
2722
2723/// Tries to parse [text](url) starting at position `i`.
2724///
2725/// Uses bracket depth counting to find the matching `]`, so that `[` characters
2726/// inside the text (e.g. `[Task] some text ([Link](url))`) don't cause a false
2727/// match on an earlier `](`.
2728fn try_parse_link(text: &str, i: usize) -> Option<(usize, &str, &str)> {
2729    let rest = &text[i..];
2730    if !rest.starts_with('[') {
2731        return None;
2732    }
2733
2734    // Find the matching ] by counting bracket depth, skipping escaped brackets
2735    let mut depth: usize = 0;
2736    let mut text_end = None;
2737    let bytes = rest.as_bytes();
2738    for (j, ch) in rest.char_indices() {
2739        match ch {
2740            '\\' if j + 1 < bytes.len() && (bytes[j + 1] == b'[' || bytes[j + 1] == b']') => {
2741                // Skip backslash-escaped bracket (issue #493)
2742            }
2743            '[' if j == 0 || bytes[j - 1] != b'\\' => depth += 1,
2744            ']' if j == 0 || bytes[j - 1] != b'\\' => {
2745                depth -= 1;
2746                if depth == 0 {
2747                    text_end = Some(j);
2748                    break;
2749                }
2750            }
2751            _ => {}
2752        }
2753    }
2754
2755    let text_end = text_end?;
2756    let link_text = &rest[1..text_end];
2757    // Must be immediately followed by (
2758    let after_bracket = &rest[text_end + 1..];
2759    if !after_bracket.starts_with('(') {
2760        return None;
2761    }
2762    let url_start = text_end + 1; // index of the '('
2763    let url_end = find_closing_paren(rest, url_start)?;
2764    let href = &rest[url_start + 1..url_end];
2765
2766    Some((i + url_end + 1, link_text, href))
2767}
2768
2769// ── ADF → Markdown ──────────────────────────────────────────────────
2770
2771/// Options for ADF-to-markdown rendering.
2772#[derive(Debug, Clone, Default)]
2773pub struct RenderOptions {
2774    /// When true, omit `localId` attributes from directive output.
2775    pub strip_local_ids: bool,
2776}
2777
2778/// Converts an ADF document to a markdown string.
2779pub fn adf_to_markdown(doc: &AdfDocument) -> Result<String> {
2780    adf_to_markdown_with_options(doc, &RenderOptions::default())
2781}
2782
2783/// Converts an ADF document to a markdown string with options.
2784pub fn adf_to_markdown_with_options(doc: &AdfDocument, opts: &RenderOptions) -> Result<String> {
2785    let mut output = String::new();
2786
2787    for (i, node) in doc.content.iter().enumerate() {
2788        if i > 0 {
2789            output.push('\n');
2790        }
2791        render_block_node(node, &mut output, opts);
2792    }
2793
2794    Ok(output)
2795}
2796
2797/// Pushes a `localId=<value>` entry to an attribute parts vec,
2798/// unless `opts.strip_local_ids` is set or the value is a placeholder.
2799/// Copies `localId` from parsed directive attrs to an ADF node's attrs if present.
2800fn pass_through_local_id(dir_attrs: &Option<crate::atlassian::attrs::Attrs>, node: &mut AdfNode) {
2801    if let Some(ref attrs) = dir_attrs {
2802        if let Some(local_id) = attrs.get("localId") {
2803            if let Some(ref mut node_attrs) = node.attrs {
2804                node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
2805            } else {
2806                node.attrs = Some(serde_json::json!({"localId": local_id}));
2807            }
2808        }
2809    }
2810}
2811
2812/// Copies `localId` from directive attrs to the node's top-level `local_id` field,
2813/// and parses `params` JSON from directive attrs into the node's `parameters` field.
2814fn pass_through_expand_params(
2815    dir_attrs: &Option<crate::atlassian::attrs::Attrs>,
2816    node: &mut AdfNode,
2817) {
2818    if let Some(ref attrs) = dir_attrs {
2819        if let Some(local_id) = attrs.get("localId") {
2820            node.local_id = Some(local_id.to_string());
2821        }
2822        if let Some(params_str) = attrs.get("params") {
2823            if let Ok(params) = serde_json::from_str(params_str) {
2824                node.parameters = Some(params);
2825            }
2826        }
2827    }
2828}
2829
2830// listItem localId is emitted as trailing inline attrs on the item line
2831// (e.g., `- item text {localId=...}`) and parsed back by extracting
2832// trailing attrs from the list item text. This avoids the block-attrs
2833// promotion issue where {localId=...} on a separate line would be
2834// applied to the parent list node.
2835
2836/// Extracts trailing `{localId=... paraLocalId=...}` from list item text.
2837/// Returns the text without the trailing attrs, the listItem localId, and
2838/// the paragraph localId if found.
2839fn extract_trailing_local_id(text: &str) -> (&str, Option<String>, Option<String>) {
2840    let trimmed = text.trim_end();
2841    if !trimmed.ends_with('}') {
2842        return (text, None, None);
2843    }
2844    // Find the opening brace.  Only match a standalone `{…}` block that is
2845    // preceded by whitespace (or is at the start of the string).  A `{` that
2846    // immediately follows `]` is part of an inline directive (e.g.
2847    // `:mention[text]{id=… localId=…}`) and must NOT be consumed here.
2848    if let Some(brace_pos) = trimmed.rfind('{') {
2849        if brace_pos > 0 && !trimmed.as_bytes()[brace_pos - 1].is_ascii_whitespace() {
2850            return (text, None, None);
2851        }
2852        let attr_str = &trimmed[brace_pos..];
2853        if let Some((_, attrs)) = parse_attrs(attr_str, 0) {
2854            let local_id = attrs.get("localId").map(str::to_string);
2855            let para_local_id = attrs.get("paraLocalId").map(str::to_string);
2856            if local_id.is_some() || para_local_id.is_some() {
2857                let before = trimmed[..brace_pos]
2858                    .strip_suffix(' ')
2859                    .unwrap_or(&trimmed[..brace_pos]);
2860                return (before, local_id, para_local_id);
2861            }
2862        }
2863    }
2864    (text, None, None)
2865}
2866
2867/// Creates a `listItem` node, optionally with a `localId` attribute
2868/// and a `paraLocalId` on its first paragraph child.
2869/// Parses the first line of a list item and any indented sub-content into
2870/// an `AdfNode::list_item`.  When the first line is a code fence opener
2871/// (`` ``` ``), the line is folded into the sub-content so the block-level
2872/// code fence parser handles it correctly (issue #511).
2873fn parse_list_item_first_line(
2874    item_text: &str,
2875    sub_lines: Vec<String>,
2876    local_id: Option<String>,
2877    para_local_id: Option<String>,
2878) -> Result<AdfNode> {
2879    if item_text.starts_with("```") {
2880        // Treat the code fence opener + indented body as block content.
2881        let mut all_lines = vec![item_text.to_string()];
2882        all_lines.extend(sub_lines);
2883        let combined = all_lines.join("\n");
2884        let nested = MarkdownParser::new(&combined).parse_blocks()?;
2885        Ok(list_item_with_local_id(nested, local_id, para_local_id))
2886    } else if let Some(media) = try_parse_media_single_from_line(item_text) {
2887        // Block-level image (issue #430).
2888        if sub_lines.is_empty() {
2889            Ok(list_item_with_local_id(
2890                vec![media],
2891                local_id,
2892                para_local_id,
2893            ))
2894        } else {
2895            let sub_text = sub_lines.join("\n");
2896            let mut nested = MarkdownParser::new(&sub_text).parse_blocks()?;
2897            let mut content = vec![media];
2898            content.append(&mut nested);
2899            Ok(list_item_with_local_id(content, local_id, para_local_id))
2900        }
2901    } else {
2902        let first_node = AdfNode::paragraph(parse_inline(item_text));
2903        if sub_lines.is_empty() {
2904            Ok(list_item_with_local_id(
2905                vec![first_node],
2906                local_id,
2907                para_local_id,
2908            ))
2909        } else {
2910            let sub_text = sub_lines.join("\n");
2911            let mut nested = MarkdownParser::new(&sub_text).parse_blocks()?;
2912            let mut content = vec![first_node];
2913            content.append(&mut nested);
2914            Ok(list_item_with_local_id(content, local_id, para_local_id))
2915        }
2916    }
2917}
2918
2919fn list_item_with_local_id(
2920    mut content: Vec<AdfNode>,
2921    local_id: Option<String>,
2922    para_local_id: Option<String>,
2923) -> AdfNode {
2924    if let Some(id) = &para_local_id {
2925        if let Some(first) = content.first_mut() {
2926            if first.node_type == "paragraph" {
2927                let node_attrs = first.attrs.get_or_insert_with(|| serde_json::json!({}));
2928                node_attrs["localId"] = serde_json::Value::String(id.clone());
2929            }
2930        }
2931    }
2932    let mut item = AdfNode::list_item(content);
2933    if let Some(id) = local_id {
2934        item.attrs = Some(serde_json::json!({"localId": id}));
2935    }
2936    item
2937}
2938
2939fn maybe_push_local_id(attrs: &serde_json::Value, parts: &mut Vec<String>, opts: &RenderOptions) {
2940    if opts.strip_local_ids {
2941        return;
2942    }
2943    if let Some(local_id) = attrs.get("localId").and_then(serde_json::Value::as_str) {
2944        if !local_id.is_empty() && local_id != "00000000-0000-0000-0000-000000000000" {
2945            parts.push(format_kv("localId", local_id));
2946        }
2947    }
2948}
2949
2950/// Renders a sequence of block nodes with blank-line separators between them.
2951fn render_block_children(children: &[AdfNode], output: &mut String, opts: &RenderOptions) {
2952    for (i, child) in children.iter().enumerate() {
2953        if i > 0 {
2954            output.push('\n');
2955        }
2956        render_block_node(child, output, opts);
2957    }
2958}
2959
2960/// Formats a float as an integer string when it has no fractional part,
2961/// otherwise as a regular float string.
2962fn fmt_f64_attr(v: f64) -> String {
2963    if v.fract() == 0.0 {
2964        format!("{}", v as i64)
2965    } else {
2966        v.to_string()
2967    }
2968}
2969
2970/// Parses a numeric attribute value string into a JSON number value that
2971/// preserves the original integer/float distinction. Returns `None` if the
2972/// string cannot be parsed as a number.
2973///
2974/// Strings without a `.` or exponent are parsed as integers (so `"800"` stays
2975/// `800`, not `800.0`); strings with a decimal point are parsed as floats.
2976fn parse_numeric_attr(s: &str) -> Option<serde_json::Value> {
2977    if s.contains('.') || s.contains('e') || s.contains('E') {
2978        s.parse::<f64>().ok().map(serde_json::Value::from)
2979    } else {
2980        s.parse::<i64>().ok().map(serde_json::Value::from)
2981    }
2982}
2983
2984/// Formats a JSON numeric value as a markdown attribute string, preserving
2985/// whether the source was stored as an integer or a float.
2986///
2987/// Returns `None` if `v` is not a number. Integer values emit as `800`;
2988/// floating-point values emit as `800.0` (or `66.66` for non-integer floats),
2989/// so that a subsequent [`parse_numeric_attr`] round-trip recovers the same
2990/// JSON type.
2991fn fmt_numeric_attr(v: &serde_json::Value) -> Option<String> {
2992    if let Some(n) = v.as_i64() {
2993        return Some(n.to_string());
2994    }
2995    if let Some(n) = v.as_u64() {
2996        return Some(n.to_string());
2997    }
2998    if let Some(n) = v.as_f64() {
2999        if n.fract() == 0.0 && n.is_finite() {
3000            return Some(format!("{n:.1}"));
3001        }
3002        return Some(n.to_string());
3003    }
3004    None
3005}
3006
3007/// Renders a block-level ADF node to markdown.
3008fn render_block_node(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
3009    match node.node_type.as_str() {
3010        "paragraph" => {
3011            let is_empty = node.content.as_ref().map_or(true, Vec::is_empty);
3012            // Build directive attr string for localId when using ::paragraph form
3013            let dir_attrs = {
3014                let mut parts = Vec::new();
3015                if let Some(ref attrs) = node.attrs {
3016                    maybe_push_local_id(attrs, &mut parts, opts);
3017                }
3018                if parts.is_empty() {
3019                    String::new()
3020                } else {
3021                    format!("{{{}}}", parts.join(" "))
3022                }
3023            };
3024            if is_empty {
3025                output.push_str(&format!("::paragraph{dir_attrs}\n"));
3026            } else {
3027                // Render to a buffer first to check if content is whitespace-only
3028                let mut buf = String::new();
3029                render_inline_content(node, &mut buf, opts);
3030                if buf.trim().is_empty() && !buf.is_empty() {
3031                    // Whitespace-only content (e.g. NBSP) would be lost as a plain
3032                    // line — use the ::paragraph[content]{attrs} directive form
3033                    output.push_str(&format!("::paragraph[{buf}]{dir_attrs}\n"));
3034                } else {
3035                    // Escape a leading list-marker pattern so paragraph
3036                    // text is not re-parsed as a list item (issue #402).
3037                    // Indent continuation lines produced by hardBreaks so
3038                    // they are not re-parsed as list items (issue #455).
3039                    let mut is_first_line = true;
3040                    for line in buf.split('\n') {
3041                        if is_first_line {
3042                            if is_list_start(line) {
3043                                output.push_str(&escape_list_marker(line));
3044                            } else {
3045                                output.push_str(line);
3046                            }
3047                            is_first_line = false;
3048                        } else {
3049                            output.push('\n');
3050                            if !line.is_empty() {
3051                                output.push_str("  ");
3052                            }
3053                            output.push_str(line);
3054                        }
3055                    }
3056                    output.push('\n');
3057                }
3058            }
3059        }
3060        "heading" => {
3061            let level = node
3062                .attrs
3063                .as_ref()
3064                .and_then(|a| a.get("level"))
3065                .and_then(serde_json::Value::as_u64)
3066                .unwrap_or(1);
3067            for _ in 0..level {
3068                output.push('#');
3069            }
3070            output.push(' ');
3071            let mut buf = String::new();
3072            render_inline_content(node, &mut buf, opts);
3073            // Indent continuation lines produced by hardBreaks so they stay
3074            // within the heading when re-parsed (issue #433).
3075            let mut is_first_line = true;
3076            for line in buf.split('\n') {
3077                if is_first_line {
3078                    output.push_str(line);
3079                    is_first_line = false;
3080                } else {
3081                    output.push('\n');
3082                    if !line.is_empty() {
3083                        output.push_str("  ");
3084                    }
3085                    output.push_str(line);
3086                }
3087            }
3088            output.push('\n');
3089        }
3090        "codeBlock" => {
3091            let language_value = node.attrs.as_ref().and_then(|a| a.get("language"));
3092            let language = language_value
3093                .and_then(serde_json::Value::as_str)
3094                .unwrap_or("");
3095            output.push_str("```");
3096            if language.is_empty() && language_value.is_some() {
3097                // Explicit empty language attr: encode as ```"" to distinguish
3098                // from a codeBlock with no attrs at all (plain ```).
3099                output.push_str("\"\"");
3100            } else {
3101                output.push_str(language);
3102            }
3103            output.push('\n');
3104            if let Some(ref content) = node.content {
3105                for child in content {
3106                    if let Some(ref text) = child.text {
3107                        output.push_str(text);
3108                    }
3109                }
3110            }
3111            output.push_str("\n```\n");
3112        }
3113        "blockquote" => {
3114            if let Some(ref content) = node.content {
3115                for (i, child) in content.iter().enumerate() {
3116                    // Separate consecutive paragraph siblings with a blank
3117                    // blockquote-prefixed line so they re-parse as distinct
3118                    // paragraphs rather than being merged into one (issue #531).
3119                    if i > 0
3120                        && child.node_type == "paragraph"
3121                        && content[i - 1].node_type == "paragraph"
3122                    {
3123                        output.push_str(">\n");
3124                    }
3125                    let mut inner = String::new();
3126                    render_block_node(child, &mut inner, opts);
3127                    for line in inner.lines() {
3128                        output.push_str("> ");
3129                        output.push_str(line);
3130                        output.push('\n');
3131                    }
3132                }
3133            }
3134        }
3135        "bulletList" => {
3136            if let Some(ref items) = node.content {
3137                for item in items {
3138                    output.push_str("- ");
3139                    let content_start = output.len();
3140                    render_list_item_content(item, output, opts);
3141                    // If the rendered content begins with a sequence the
3142                    // bullet-list parser would interpret as a task checkbox
3143                    // marker, escape the leading `[` so the round-trip
3144                    // preserves this as a `bulletList` rather than promoting
3145                    // it to a `taskList` (issue #548).
3146                    if starts_with_task_marker(&output[content_start..]) {
3147                        output.insert(content_start, '\\');
3148                    }
3149                }
3150            }
3151        }
3152        "orderedList" => {
3153            let start = node
3154                .attrs
3155                .as_ref()
3156                .and_then(|a| a.get("order"))
3157                .and_then(serde_json::Value::as_u64)
3158                .unwrap_or(1);
3159            if let Some(ref items) = node.content {
3160                for (i, item) in items.iter().enumerate() {
3161                    let num = start + i as u64;
3162                    output.push_str(&format!("{num}. "));
3163                    render_list_item_content(item, output, opts);
3164                }
3165            }
3166        }
3167        "taskList" => {
3168            if let Some(ref items) = node.content {
3169                for item in items {
3170                    if item.node_type == "taskList" {
3171                        // A nested taskList is a sibling child of the outer
3172                        // taskList — render it indented so it round-trips back
3173                        // as a taskList, not a taskItem (issue #506).
3174                        let mut nested = String::new();
3175                        render_block_node(item, &mut nested, opts);
3176                        for line in nested.lines() {
3177                            output.push_str("  ");
3178                            output.push_str(line);
3179                            output.push('\n');
3180                        }
3181                    } else {
3182                        let state = item
3183                            .attrs
3184                            .as_ref()
3185                            .and_then(|a| a.get("state"))
3186                            .and_then(serde_json::Value::as_str)
3187                            .unwrap_or("TODO");
3188                        if state == "DONE" {
3189                            output.push_str("- [x] ");
3190                        } else {
3191                            output.push_str("- [ ] ");
3192                        }
3193                        render_list_item_content(item, output, opts);
3194                    }
3195                }
3196            }
3197        }
3198        "rule" => {
3199            output.push_str("---\n");
3200        }
3201        "table" => {
3202            render_table(node, output, opts);
3203        }
3204        "mediaSingle" => {
3205            if let Some(ref content) = node.content {
3206                for child in content {
3207                    if child.node_type == "media" {
3208                        render_media(child, node.attrs.as_ref(), output, opts);
3209                    }
3210                }
3211                for child in content {
3212                    if child.node_type == "caption" {
3213                        let mut cap_parts = Vec::new();
3214                        if let Some(ref attrs) = child.attrs {
3215                            maybe_push_local_id(attrs, &mut cap_parts, opts);
3216                        }
3217                        if cap_parts.is_empty() {
3218                            output.push_str(":::caption\n");
3219                        } else {
3220                            output.push_str(&format!(":::caption{{{}}}\n", cap_parts.join(" ")));
3221                        }
3222                        if let Some(ref caption_content) = child.content {
3223                            for inline in caption_content {
3224                                render_inline_node(inline, output, opts);
3225                            }
3226                            output.push('\n');
3227                        }
3228                        output.push_str(":::\n");
3229                    }
3230                }
3231            }
3232        }
3233        "blockCard" => {
3234            if let Some(ref attrs) = node.attrs {
3235                let url = attrs
3236                    .get("url")
3237                    .and_then(serde_json::Value::as_str)
3238                    .unwrap_or("");
3239                let mut attr_parts = Vec::new();
3240                if url_safe_in_bracket_content(url) {
3241                    output.push_str(&format!("::card[{url}]"));
3242                } else {
3243                    // URL would break `::card[URL]` parsing; use quoted attr form.
3244                    output.push_str("::card[]");
3245                    let escaped = url.replace('\\', "\\\\").replace('"', "\\\"");
3246                    attr_parts.push(format!("url=\"{escaped}\""));
3247                }
3248                if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3249                    attr_parts.push(format!("layout={layout}"));
3250                }
3251                if let Some(width) = attrs.get("width").and_then(serde_json::Value::as_u64) {
3252                    attr_parts.push(format!("width={width}"));
3253                }
3254                if !attr_parts.is_empty() {
3255                    output.push_str(&format!("{{{}}}", attr_parts.join(" ")));
3256                }
3257                output.push('\n');
3258            }
3259        }
3260        "embedCard" => {
3261            if let Some(ref attrs) = node.attrs {
3262                let url = attrs
3263                    .get("url")
3264                    .and_then(serde_json::Value::as_str)
3265                    .unwrap_or("");
3266                output.push_str(&format!("::embed[{url}]"));
3267                let mut attr_parts = Vec::new();
3268                if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3269                    attr_parts.push(format!("layout={layout}"));
3270                }
3271                if let Some(h) = attrs
3272                    .get("originalHeight")
3273                    .and_then(serde_json::Value::as_f64)
3274                {
3275                    attr_parts.push(format!("originalHeight={}", fmt_f64_attr(h)));
3276                }
3277                if let Some(w) = attrs.get("width").and_then(serde_json::Value::as_f64) {
3278                    attr_parts.push(format!("width={}", fmt_f64_attr(w)));
3279                }
3280                if !attr_parts.is_empty() {
3281                    output.push_str(&format!("{{{}}}", attr_parts.join(" ")));
3282                }
3283                output.push('\n');
3284            }
3285        }
3286        "extension" => {
3287            if let Some(ref attrs) = node.attrs {
3288                let ext_type = attrs
3289                    .get("extensionType")
3290                    .and_then(serde_json::Value::as_str)
3291                    .unwrap_or("");
3292                let ext_key = attrs
3293                    .get("extensionKey")
3294                    .and_then(serde_json::Value::as_str)
3295                    .unwrap_or("");
3296                let mut attr_parts = vec![format!("type={ext_type}"), format!("key={ext_key}")];
3297                if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3298                    attr_parts.push(format!("layout={layout}"));
3299                }
3300                if let Some(params) = attrs.get("parameters") {
3301                    if let Ok(json_str) = serde_json::to_string(params) {
3302                        attr_parts.push(format!("params='{json_str}'"));
3303                    }
3304                }
3305                maybe_push_local_id(attrs, &mut attr_parts, opts);
3306                output.push_str(&format!("::extension{{{}}}\n", attr_parts.join(" ")));
3307            }
3308        }
3309        "panel" => {
3310            let panel_type = node
3311                .attrs
3312                .as_ref()
3313                .and_then(|a| a.get("panelType"))
3314                .and_then(serde_json::Value::as_str)
3315                .unwrap_or("info");
3316            let mut attr_parts = vec![format!("type={panel_type}")];
3317            if let Some(ref attrs) = node.attrs {
3318                if let Some(icon) = attrs.get("panelIcon").and_then(serde_json::Value::as_str) {
3319                    attr_parts.push(format!("icon=\"{icon}\""));
3320                }
3321                if let Some(color) = attrs.get("panelColor").and_then(serde_json::Value::as_str) {
3322                    attr_parts.push(format!("color=\"{color}\""));
3323                }
3324            }
3325            output.push_str(&format!(":::panel{{{}}}\n", attr_parts.join(" ")));
3326            if let Some(ref content) = node.content {
3327                render_block_children(content, output, opts);
3328            }
3329            output.push_str(":::\n");
3330        }
3331        "expand" | "nestedExpand" => {
3332            let directive_name = if node.node_type == "nestedExpand" {
3333                "nested-expand"
3334            } else {
3335                "expand"
3336            };
3337            let mut attr_parts = Vec::new();
3338            if let Some(t) = node
3339                .attrs
3340                .as_ref()
3341                .and_then(|a| a.get("title"))
3342                .and_then(serde_json::Value::as_str)
3343            {
3344                attr_parts.push(format!("title=\"{t}\""));
3345            }
3346            // Check top-level localId first, then fall back to attrs.localId
3347            if let Some(ref lid) = node.local_id {
3348                if !opts.strip_local_ids && lid != "00000000-0000-0000-0000-000000000000" {
3349                    attr_parts.push(format!("localId={lid}"));
3350                }
3351            } else if let Some(ref attrs) = node.attrs {
3352                maybe_push_local_id(attrs, &mut attr_parts, opts);
3353            }
3354            // Emit top-level parameters as params='...'
3355            if let Some(ref params) = node.parameters {
3356                if let Ok(json_str) = serde_json::to_string(params) {
3357                    attr_parts.push(format!("params='{json_str}'"));
3358                }
3359            }
3360            if attr_parts.is_empty() {
3361                output.push_str(&format!(":::{directive_name}\n"));
3362            } else {
3363                output.push_str(&format!(
3364                    ":::{directive_name}{{{}}}\n",
3365                    attr_parts.join(" ")
3366                ));
3367            }
3368            if let Some(ref content) = node.content {
3369                render_block_children(content, output, opts);
3370            }
3371            output.push_str(":::\n");
3372        }
3373        "layoutSection" => {
3374            output.push_str("::::layout\n");
3375            if let Some(ref content) = node.content {
3376                for child in content {
3377                    if child.node_type == "layoutColumn" {
3378                        let width_str = child
3379                            .attrs
3380                            .as_ref()
3381                            .and_then(|a| a.get("width"))
3382                            .and_then(fmt_numeric_attr)
3383                            .unwrap_or_else(|| "50".to_string());
3384                        let mut parts = vec![format!("width={width_str}")];
3385                        if let Some(ref attrs) = child.attrs {
3386                            maybe_push_local_id(attrs, &mut parts, opts);
3387                        }
3388                        output.push_str(&format!(":::column{{{}}}\n", parts.join(" ")));
3389                        if let Some(ref col_content) = child.content {
3390                            render_block_children(col_content, output, opts);
3391                        }
3392                        output.push_str(":::\n");
3393                    }
3394                }
3395            }
3396            output.push_str("::::\n");
3397        }
3398        "decisionList" => {
3399            output.push_str(":::decisions\n");
3400            if let Some(ref content) = node.content {
3401                for item in content {
3402                    output.push_str("- <> ");
3403                    render_list_item_content(item, output, opts);
3404                }
3405            }
3406            output.push_str(":::\n");
3407        }
3408        "bodiedExtension" => {
3409            if let Some(ref attrs) = node.attrs {
3410                let ext_type = attrs
3411                    .get("extensionType")
3412                    .and_then(serde_json::Value::as_str)
3413                    .unwrap_or("");
3414                let ext_key = attrs
3415                    .get("extensionKey")
3416                    .and_then(serde_json::Value::as_str)
3417                    .unwrap_or("");
3418                let mut attr_parts = vec![format!("type={ext_type}"), format!("key={ext_key}")];
3419                if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3420                    attr_parts.push(format!("layout={layout}"));
3421                }
3422                if let Some(params) = attrs.get("parameters") {
3423                    if let Ok(json_str) = serde_json::to_string(params) {
3424                        attr_parts.push(format!("params='{json_str}'"));
3425                    }
3426                }
3427                maybe_push_local_id(attrs, &mut attr_parts, opts);
3428                output.push_str(&format!(":::extension{{{}}}\n", attr_parts.join(" ")));
3429                if let Some(ref content) = node.content {
3430                    render_block_children(content, output, opts);
3431                }
3432                output.push_str(":::\n");
3433            }
3434        }
3435        _ => {
3436            // Preserve unsupported nodes as JSON in adf-unsupported code blocks
3437            if let Ok(json) = serde_json::to_string_pretty(node) {
3438                output.push_str("```adf-unsupported\n");
3439                output.push_str(&json);
3440                output.push_str("\n```\n");
3441            }
3442        }
3443    }
3444
3445    // Emit block-level attribute marks (align, indent, breakout) and localId
3446    let mut parts = Vec::new();
3447    if let Some(ref marks) = node.marks {
3448        for mark in marks {
3449            match mark.mark_type.as_str() {
3450                "alignment" => {
3451                    if let Some(align) = mark
3452                        .attrs
3453                        .as_ref()
3454                        .and_then(|a| a.get("align"))
3455                        .and_then(serde_json::Value::as_str)
3456                    {
3457                        parts.push(format!("align={align}"));
3458                    }
3459                }
3460                "indentation" => {
3461                    if let Some(level) = mark
3462                        .attrs
3463                        .as_ref()
3464                        .and_then(|a| a.get("level"))
3465                        .and_then(serde_json::Value::as_u64)
3466                    {
3467                        parts.push(format!("indent={level}"));
3468                    }
3469                }
3470                "breakout" => {
3471                    if let Some(mode) = mark
3472                        .attrs
3473                        .as_ref()
3474                        .and_then(|a| a.get("mode"))
3475                        .and_then(serde_json::Value::as_str)
3476                    {
3477                        parts.push(format!("breakout={mode}"));
3478                    }
3479                    if let Some(width) = mark
3480                        .attrs
3481                        .as_ref()
3482                        .and_then(|a| a.get("width"))
3483                        .and_then(serde_json::Value::as_u64)
3484                    {
3485                        parts.push(format!("breakoutWidth={width}"));
3486                    }
3487                }
3488                _ => {}
3489            }
3490        }
3491    }
3492    // Skip localId for node types that already include it in their directive attrs.
3493    // For paragraphs, localId is included in the ::paragraph directive when the
3494    // paragraph uses directive form (empty or whitespace-only content).
3495    let para_used_directive = node.node_type == "paragraph" && {
3496        let is_empty = node.content.as_ref().map_or(true, Vec::is_empty);
3497        if is_empty {
3498            true
3499        } else {
3500            let mut buf = String::new();
3501            render_inline_content(node, &mut buf, opts);
3502            buf.trim().is_empty() && !buf.is_empty()
3503        }
3504    };
3505    if !matches!(node.node_type.as_str(), "expand" | "nestedExpand") && !para_used_directive {
3506        if let Some(ref attrs) = node.attrs {
3507            maybe_push_local_id(attrs, &mut parts, opts);
3508        }
3509    }
3510    // orderedList with explicit `attrs.order=1` needs a trailing `{order=1}`
3511    // signal so the round-trip can distinguish explicit default from omitted
3512    // attrs (issue #547). Values other than 1 are already encoded by the
3513    // list marker, so no signal is needed.
3514    if node.node_type == "orderedList" {
3515        if let Some(ref attrs) = node.attrs {
3516            if attrs.get("order").and_then(serde_json::Value::as_u64) == Some(1) {
3517                parts.push("order=1".to_string());
3518            }
3519        }
3520    }
3521    if !parts.is_empty() {
3522        output.push_str(&format!("{{{}}}\n", parts.join(" ")));
3523    }
3524}
3525
3526/// Renders the content of a list item (unwraps the paragraph layer).
3527/// Nested block children (e.g. sub-lists) are indented with two spaces.
3528///
3529/// Some ADF producers (e.g. Confluence) emit `taskItem` content without a
3530/// paragraph wrapper — the inline nodes sit directly inside the item.  We
3531/// detect this by checking whether the first child is an inline node type
3532/// and, if so, render *all* leading inline children on the first line.
3533fn render_list_item_content(item: &AdfNode, output: &mut String, opts: &RenderOptions) {
3534    let Some(ref content) = item.content else {
3535        // Still emit localId and newline for items with no content (e.g. empty taskItem).
3536        let bare = AdfNode::text("");
3537        emit_list_item_local_ids(item, &bare, output, opts);
3538        output.push('\n');
3539        return;
3540    };
3541    if content.is_empty() {
3542        let bare = AdfNode::text("");
3543        emit_list_item_local_ids(item, &bare, output, opts);
3544        output.push('\n');
3545        return;
3546    }
3547    let first = &content[0];
3548    let rest_start;
3549    if first.node_type == "paragraph" {
3550        let mut buf = String::new();
3551        render_inline_content(first, &mut buf, opts);
3552        // A trailing hardBreak produces a trailing `\\\n` in the buffer.
3553        // Strip the final newline so it doesn't create a blank line after
3554        // the list item marker, which would split the list on re-parse
3555        // (issue #472).  The `\` is kept so round-trip preserves the
3556        // hardBreak, and `output.push('\n')` below supplies the terminator.
3557        let buf = buf.trim_end_matches('\n');
3558        // Indent continuation lines produced by hardBreaks so they stay
3559        // within the list item when re-parsed (issue #402).
3560        let mut is_first_line = true;
3561        for line in buf.split('\n') {
3562            if is_first_line {
3563                output.push_str(line);
3564                is_first_line = false;
3565            } else {
3566                output.push('\n');
3567                if !line.is_empty() {
3568                    output.push_str("  ");
3569                }
3570                output.push_str(line);
3571            }
3572        }
3573        // Emit paragraph + listItem localIds as trailing inline attrs on the first line
3574        emit_list_item_local_ids(item, first, output, opts);
3575        output.push('\n');
3576        rest_start = 1;
3577    } else if is_inline_node_type(&first.node_type) {
3578        // Inline nodes without a paragraph wrapper — render them directly.
3579        rest_start = content
3580            .iter()
3581            .position(|c| !is_inline_node_type(&c.node_type))
3582            .unwrap_or(content.len());
3583        let mut buf = String::new();
3584        for child in &content[..rest_start] {
3585            render_inline_node(child, &mut buf, opts);
3586        }
3587        // Indent continuation lines produced by hardBreaks so they stay
3588        // within the list item when re-parsed (issue #521).
3589        let buf = buf.trim_end_matches('\n');
3590        let mut is_first_line = true;
3591        for line in buf.split('\n') {
3592            if is_first_line {
3593                output.push_str(line);
3594                is_first_line = false;
3595            } else {
3596                output.push('\n');
3597                if !line.is_empty() {
3598                    output.push_str("  ");
3599                }
3600                output.push_str(line);
3601            }
3602        }
3603        // No paragraph wrapper — pass a bare node so paraLocalId is omitted.
3604        let bare = AdfNode::text("");
3605        emit_list_item_local_ids(item, &bare, output, opts);
3606        output.push('\n');
3607        // Any remaining children are block nodes — fall through to the
3608        // indented-block loop below.
3609    } else if first.node_type == "taskItem" {
3610        // Malformed ADF: taskItem.content contains nested taskItem nodes
3611        // directly (seen in some Confluence pages).  Render them as an
3612        // indented nested task list to preserve the data without
3613        // corrupting the surrounding structure (issue #489).
3614        let bare = AdfNode::text("");
3615        emit_list_item_local_ids(item, &bare, output, opts);
3616        output.push('\n');
3617        for child in content {
3618            if child.node_type == "taskItem" {
3619                let state = child
3620                    .attrs
3621                    .as_ref()
3622                    .and_then(|a| a.get("state"))
3623                    .and_then(serde_json::Value::as_str)
3624                    .unwrap_or("TODO");
3625                let marker = if state == "DONE" { "- [x] " } else { "- [ ] " };
3626                output.push_str("  ");
3627                output.push_str(marker);
3628                render_list_item_content(child, output, opts);
3629            } else {
3630                let mut nested = String::new();
3631                render_block_node(child, &mut nested, opts);
3632                for line in nested.lines() {
3633                    output.push_str("  ");
3634                    output.push_str(line);
3635                    output.push('\n');
3636                }
3637            }
3638        }
3639        return;
3640    } else {
3641        // Block-level first child (e.g. codeBlock, mediaSingle).
3642        // Render to a buffer so we can:
3643        //  1. Append listItem localId attrs to the first line (issue #525)
3644        //  2. Indent continuation lines so multi-line blocks stay inside
3645        //     the list item (issue #511)
3646        let mut buf = String::new();
3647        render_block_node(first, &mut buf, opts);
3648        let bare = AdfNode::text("");
3649        let mut is_first = true;
3650        for line in buf.lines() {
3651            if is_first {
3652                output.push_str(line);
3653                emit_list_item_local_ids(item, &bare, output, opts);
3654                output.push('\n');
3655                is_first = false;
3656            } else {
3657                output.push_str("  ");
3658                output.push_str(line);
3659                output.push('\n');
3660            }
3661        }
3662        rest_start = 1;
3663    }
3664    let rest = &content[rest_start..];
3665    for (i, child) in rest.iter().enumerate() {
3666        // Separate consecutive paragraph siblings with a blank indented
3667        // line so they re-parse as distinct paragraphs rather than being
3668        // merged into one (issue #522).
3669        if child.node_type == "paragraph" {
3670            let prev_is_para = if i == 0 {
3671                // First rest child — check whether the first-line node
3672                // (rendered above) was a paragraph.
3673                first.node_type == "paragraph"
3674            } else {
3675                rest[i - 1].node_type == "paragraph"
3676            };
3677            if prev_is_para {
3678                output.push_str("  \n");
3679            }
3680        }
3681        let mut nested = String::new();
3682        render_block_node(child, &mut nested, opts);
3683        for line in nested.lines() {
3684            output.push_str("  ");
3685            output.push_str(line);
3686            output.push('\n');
3687        }
3688    }
3689}
3690
3691/// Returns `true` if the given ADF node type is an inline node.
3692fn is_inline_node_type(node_type: &str) -> bool {
3693    matches!(
3694        node_type,
3695        "text"
3696            | "hardBreak"
3697            | "inlineCard"
3698            | "emoji"
3699            | "mention"
3700            | "status"
3701            | "date"
3702            | "placeholder"
3703            | "mediaInline"
3704    )
3705}
3706
3707/// Emits trailing `{localId=... paraLocalId=...}` on a list item line
3708/// for both the listItem and its first (unwrapped) paragraph.
3709fn emit_list_item_local_ids(
3710    item: &AdfNode,
3711    paragraph: &AdfNode,
3712    output: &mut String,
3713    opts: &RenderOptions,
3714) {
3715    if opts.strip_local_ids {
3716        return;
3717    }
3718    let mut parts = Vec::new();
3719    if let Some(ref attrs) = item.attrs {
3720        maybe_push_local_id(attrs, &mut parts, opts);
3721    }
3722    if paragraph.node_type == "paragraph" {
3723        let has_real_id = paragraph
3724            .attrs
3725            .as_ref()
3726            .and_then(|a| a.get("localId"))
3727            .and_then(serde_json::Value::as_str)
3728            .filter(|id| !id.is_empty() && *id != "00000000-0000-0000-0000-000000000000");
3729        if let Some(local_id) = has_real_id {
3730            parts.push(format!("paraLocalId={local_id}"));
3731        } else if item.node_type == "taskItem" {
3732            // taskItem content may or may not have a paragraph wrapper;
3733            // emit a sentinel so the round-trip can distinguish the two
3734            // forms and restore the wrapper (issue #478).
3735            parts.push("paraLocalId=_".to_string());
3736        }
3737    }
3738    if !parts.is_empty() {
3739        output.push_str(&format!(" {{{}}}", parts.join(" ")));
3740    }
3741}
3742
3743/// Renders a table node, choosing between pipe table and directive table form.
3744fn render_table(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
3745    let Some(ref rows) = node.content else {
3746        return;
3747    };
3748
3749    if table_qualifies_for_pipe_syntax(rows) {
3750        render_pipe_table(node, rows, output, opts);
3751    } else {
3752        render_directive_table(node, rows, output, opts);
3753    }
3754}
3755
3756/// Checks whether all cells qualify for GFM pipe table syntax:
3757/// - Every cell has exactly one paragraph child with only inline nodes
3758/// - All `tableHeader` nodes appear exclusively in the first row
3759/// - The first row must contain at least one `tableHeader` (pipe tables
3760///   always treat the first row as headers, so `tableCell`-only first rows
3761///   must use directive form to preserve the cell type)
3762fn table_qualifies_for_pipe_syntax(rows: &[AdfNode]) -> bool {
3763    // Tables with caption nodes must use directive form
3764    if rows.iter().any(|n| n.node_type == "caption") {
3765        return false;
3766    }
3767    let mut first_row_has_header = false;
3768    for (row_idx, row) in rows.iter().enumerate() {
3769        let Some(ref cells) = row.content else {
3770            continue;
3771        };
3772        for cell in cells {
3773            // Header cells outside first row → must use directive form
3774            if row_idx > 0 && cell.node_type == "tableHeader" {
3775                return false;
3776            }
3777            if row_idx == 0 && cell.node_type == "tableHeader" {
3778                first_row_has_header = true;
3779            }
3780            // Check cell has exactly one paragraph with only inline content
3781            let Some(ref content) = cell.content else {
3782                continue;
3783            };
3784            if content.len() != 1 || content[0].node_type != "paragraph" {
3785                return false;
3786            }
3787            // hardBreak inside a cell produces a newline that breaks pipe
3788            // table syntax — fall back to directive form
3789            if cell_contains_hard_break(&content[0]) {
3790                return false;
3791            }
3792            // Cell-level marks (e.g., border) cannot be represented in pipe
3793            // form — fall back to directive form
3794            if cell.marks.as_ref().is_some_and(|m| !m.is_empty()) {
3795                return false;
3796            }
3797            // Paragraph-level localId would be lost in pipe form (the paragraph
3798            // is unwrapped into the cell text) — fall back to directive form
3799            if content[0]
3800                .attrs
3801                .as_ref()
3802                .and_then(|a| a.get("localId"))
3803                .is_some()
3804            {
3805                return false;
3806            }
3807        }
3808    }
3809    // First row must have at least one tableHeader for pipe syntax;
3810    // otherwise the round-trip would convert tableCell → tableHeader.
3811    first_row_has_header
3812}
3813
3814/// Returns true if a paragraph node contains any `hardBreak` inline nodes.
3815fn cell_contains_hard_break(paragraph: &AdfNode) -> bool {
3816    paragraph
3817        .content
3818        .as_ref()
3819        .is_some_and(|nodes| nodes.iter().any(|n| n.node_type == "hardBreak"))
3820}
3821
3822/// Renders a table as GFM pipe syntax.
3823fn render_pipe_table(node: &AdfNode, rows: &[AdfNode], output: &mut String, opts: &RenderOptions) {
3824    for (row_idx, row) in rows.iter().enumerate() {
3825        let Some(ref cells) = row.content else {
3826            continue;
3827        };
3828
3829        output.push('|');
3830        for cell in cells {
3831            output.push(' ');
3832            let mut cell_buf = String::new();
3833            render_cell_attrs_prefix(cell, &mut cell_buf);
3834            render_inline_content_from_first_paragraph(cell, &mut cell_buf, opts);
3835            output.push_str(&escape_pipes_in_cell(&cell_buf));
3836            output.push_str(" |");
3837        }
3838        output.push('\n');
3839
3840        // Add separator after header row
3841        if row_idx == 0 {
3842            output.push('|');
3843            for cell in cells {
3844                let align = get_cell_paragraph_alignment(cell);
3845                match align {
3846                    Some("center") => output.push_str(" :---: |"),
3847                    Some("end") => output.push_str(" ---: |"),
3848                    _ => output.push_str(" --- |"),
3849                }
3850            }
3851            output.push('\n');
3852        }
3853    }
3854
3855    // Emit table-level attrs if present
3856    render_table_level_attrs(node, output, opts);
3857}
3858
3859/// Renders a table as `::::table` directive syntax (block-content cells).
3860fn render_directive_table(
3861    node: &AdfNode,
3862    rows: &[AdfNode],
3863    output: &mut String,
3864    opts: &RenderOptions,
3865) {
3866    // Opening fence with attrs
3867    let mut attr_parts = Vec::new();
3868    if let Some(ref attrs) = node.attrs {
3869        if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3870            attr_parts.push(format!("layout={layout}"));
3871        }
3872        if let Some(numbered) = attrs
3873            .get("isNumberColumnEnabled")
3874            .and_then(serde_json::Value::as_bool)
3875        {
3876            if numbered {
3877                attr_parts.push("numbered".to_string());
3878            } else {
3879                attr_parts.push("numbered=false".to_string());
3880            }
3881        }
3882        if let Some(tw) = attrs.get("width").and_then(serde_json::Value::as_f64) {
3883            let tw_str = if tw.fract() == 0.0 {
3884                (tw as u64).to_string()
3885            } else {
3886                tw.to_string()
3887            };
3888            attr_parts.push(format!("width={tw_str}"));
3889        }
3890        maybe_push_local_id(attrs, &mut attr_parts, opts);
3891    }
3892    if attr_parts.is_empty() {
3893        output.push_str("::::table\n");
3894    } else {
3895        output.push_str(&format!("::::table{{{}}}\n", attr_parts.join(" ")));
3896    }
3897
3898    for row in rows {
3899        if row.node_type == "caption" {
3900            let mut cap_parts = Vec::new();
3901            if let Some(ref attrs) = row.attrs {
3902                maybe_push_local_id(attrs, &mut cap_parts, opts);
3903            }
3904            if cap_parts.is_empty() {
3905                output.push_str(":::caption\n");
3906            } else {
3907                output.push_str(&format!(":::caption{{{}}}\n", cap_parts.join(" ")));
3908            }
3909            if let Some(ref content) = row.content {
3910                for child in content {
3911                    render_inline_node(child, output, opts);
3912                }
3913                output.push('\n');
3914            }
3915            output.push_str(":::\n");
3916            continue;
3917        }
3918        let Some(ref cells) = row.content else {
3919            continue;
3920        };
3921        // Emit :::tr with optional localId
3922        let mut tr_attrs = Vec::new();
3923        if let Some(ref attrs) = row.attrs {
3924            maybe_push_local_id(attrs, &mut tr_attrs, opts);
3925        }
3926        if tr_attrs.is_empty() {
3927            output.push_str(":::tr\n");
3928        } else {
3929            output.push_str(&format!(":::tr{{{}}}\n", tr_attrs.join(" ")));
3930        }
3931        for cell in cells {
3932            let directive_name = if cell.node_type == "tableHeader" {
3933                "th"
3934            } else {
3935                "td"
3936            };
3937            let mut cell_attr_str = build_cell_attrs_string(cell);
3938            // Append localId to cell attrs if present
3939            if let Some(ref attrs) = cell.attrs {
3940                let mut lid_parts = Vec::new();
3941                maybe_push_local_id(attrs, &mut lid_parts, opts);
3942                if !lid_parts.is_empty() {
3943                    if !cell_attr_str.is_empty() {
3944                        cell_attr_str.push(' ');
3945                    }
3946                    cell_attr_str.push_str(&lid_parts.join(" "));
3947                }
3948            }
3949            // Append border mark attrs if present
3950            if let Some(ref marks) = cell.marks {
3951                for mark in marks {
3952                    if mark.mark_type == "border" {
3953                        if let Some(ref attrs) = mark.attrs {
3954                            if let Some(color) =
3955                                attrs.get("color").and_then(serde_json::Value::as_str)
3956                            {
3957                                if !cell_attr_str.is_empty() {
3958                                    cell_attr_str.push(' ');
3959                                }
3960                                cell_attr_str.push_str(&format!("border-color={color}"));
3961                            }
3962                            if let Some(size) =
3963                                attrs.get("size").and_then(serde_json::Value::as_u64)
3964                            {
3965                                if !cell_attr_str.is_empty() {
3966                                    cell_attr_str.push(' ');
3967                                }
3968                                cell_attr_str.push_str(&format!("border-size={size}"));
3969                            }
3970                        }
3971                    }
3972                }
3973            }
3974            let has_marks = cell.marks.as_ref().is_some_and(|m| !m.is_empty());
3975            if cell_attr_str.is_empty() && cell.attrs.is_none() && !has_marks {
3976                output.push_str(&format!(":::{directive_name}\n"));
3977            } else {
3978                output.push_str(&format!(":::{directive_name}{{{cell_attr_str}}}\n"));
3979            }
3980            if let Some(ref content) = cell.content {
3981                render_block_children(content, output, opts);
3982            }
3983            output.push_str(":::\n");
3984        }
3985        output.push_str(":::\n");
3986    }
3987
3988    output.push_str("::::\n");
3989}
3990
3991/// Returns `true` when an attribute value must be quoted to survive round-trip
3992/// through the `{key=value}` attribute parser (which stops unquoted values at
3993/// whitespace or `}`).
3994fn needs_attr_quoting(value: &str) -> bool {
3995    value.contains(|c: char| c.is_whitespace() || c == '}' || c == '(' || c == ')' || c == ',')
3996}
3997
3998/// Builds a JFM attribute string from ADF cell attributes.
3999fn build_cell_attrs_string(cell: &AdfNode) -> String {
4000    let Some(ref attrs) = cell.attrs else {
4001        return String::new();
4002    };
4003    let mut parts = Vec::new();
4004    if let Some(colspan) = attrs.get("colspan").and_then(serde_json::Value::as_u64) {
4005        parts.push(format!("colspan={colspan}"));
4006    }
4007    if let Some(rowspan) = attrs.get("rowspan").and_then(serde_json::Value::as_u64) {
4008        parts.push(format!("rowspan={rowspan}"));
4009    }
4010    if let Some(bg) = attrs.get("background").and_then(serde_json::Value::as_str) {
4011        if needs_attr_quoting(bg) {
4012            let escaped = bg.replace('\\', "\\\\").replace('"', "\\\"");
4013            parts.push(format!("bg=\"{escaped}\""));
4014        } else {
4015            parts.push(format!("bg={bg}"));
4016        }
4017    }
4018    if let Some(colwidth) = attrs.get("colwidth").and_then(serde_json::Value::as_array) {
4019        let widths: Vec<String> = colwidth
4020            .iter()
4021            .filter_map(|v| {
4022                // Preserve the original number type: integers stay as integers,
4023                // floats stay as floats (e.g. Confluence's 254.0 representation).
4024                if let Some(n) = v.as_u64() {
4025                    Some(n.to_string())
4026                } else if let Some(n) = v.as_f64() {
4027                    if n.fract() == 0.0 {
4028                        format!("{n:.1}")
4029                    } else {
4030                        n.to_string()
4031                    }
4032                    .into()
4033                } else {
4034                    None
4035                }
4036            })
4037            .collect();
4038        if !widths.is_empty() {
4039            parts.push(format!("colwidth={}", widths.join(",")));
4040        }
4041    }
4042    parts.join(" ")
4043}
4044
4045/// Renders `{attrs}` prefix for a pipe table cell (background, colspan, etc.).
4046fn render_cell_attrs_prefix(cell: &AdfNode, output: &mut String) {
4047    let Some(ref _attrs) = cell.attrs else {
4048        return;
4049    };
4050    let attr_str = build_cell_attrs_string(cell);
4051    if attr_str.is_empty() {
4052        output.push_str("{} ");
4053    } else {
4054        output.push_str(&format!("{{{attr_str}}} "));
4055    }
4056}
4057
4058/// Gets the alignment mark value from the paragraph inside a table cell.
4059fn get_cell_paragraph_alignment(cell: &AdfNode) -> Option<&str> {
4060    let content = cell.content.as_ref()?;
4061    let para = content.first()?;
4062    let marks = para.marks.as_ref()?;
4063    marks.iter().find_map(|m| {
4064        if m.mark_type == "alignment" {
4065            m.attrs
4066                .as_ref()
4067                .and_then(|a| a.get("align"))
4068                .and_then(serde_json::Value::as_str)
4069        } else {
4070            None
4071        }
4072    })
4073}
4074
4075/// Emits table-level attributes if present.
4076fn render_table_level_attrs(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4077    if let Some(ref attrs) = node.attrs {
4078        let mut parts = Vec::new();
4079        if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
4080            parts.push(format!("layout={layout}"));
4081        }
4082        if let Some(numbered) = attrs
4083            .get("isNumberColumnEnabled")
4084            .and_then(serde_json::Value::as_bool)
4085        {
4086            if numbered {
4087                parts.push("numbered".to_string());
4088            } else {
4089                parts.push("numbered=false".to_string());
4090            }
4091        }
4092        if let Some(tw_str) = attrs.get("width").and_then(fmt_numeric_attr) {
4093            parts.push(format!("width={tw_str}"));
4094        }
4095        maybe_push_local_id(attrs, &mut parts, opts);
4096        if !parts.is_empty() {
4097            output.push_str(&format!("{{{}}}\n", parts.join(" ")));
4098        }
4099    }
4100}
4101
4102/// Renders inline content from the first paragraph child of a table cell.
4103fn render_inline_content_from_first_paragraph(
4104    cell: &AdfNode,
4105    output: &mut String,
4106    opts: &RenderOptions,
4107) {
4108    if let Some(ref content) = cell.content {
4109        if let Some(first) = content.first() {
4110            if first.node_type == "paragraph" {
4111                render_inline_content(first, output, opts);
4112            }
4113        }
4114    }
4115}
4116
4117/// Appends border mark attributes (border-color, border-size) to a parts vec.
4118fn push_border_mark_attrs(marks: &Option<Vec<AdfMark>>, parts: &mut Vec<String>) {
4119    if let Some(ref marks) = marks {
4120        for mark in marks {
4121            if mark.mark_type == "border" {
4122                if let Some(ref attrs) = mark.attrs {
4123                    if let Some(color) = attrs.get("color").and_then(serde_json::Value::as_str) {
4124                        parts.push(format!("border-color={color}"));
4125                    }
4126                    if let Some(size) = attrs.get("size").and_then(serde_json::Value::as_u64) {
4127                        parts.push(format!("border-size={size}"));
4128                    }
4129                }
4130            }
4131        }
4132    }
4133}
4134
4135/// Renders a media node as a markdown image, with optional parent (mediaSingle) attrs.
4136fn render_media(
4137    node: &AdfNode,
4138    parent_attrs: Option<&serde_json::Value>,
4139    output: &mut String,
4140    opts: &RenderOptions,
4141) {
4142    if let Some(ref attrs) = node.attrs {
4143        let media_type = attrs
4144            .get("type")
4145            .and_then(serde_json::Value::as_str)
4146            .unwrap_or("external");
4147        let alt = attrs
4148            .get("alt")
4149            .and_then(serde_json::Value::as_str)
4150            .unwrap_or("");
4151
4152        if media_type == "file" {
4153            // Confluence file attachment — encode metadata in {attrs} block so it survives round-trip
4154            output.push_str(&format!("![{alt}]()"));
4155            let mut parts = vec!["type=file".to_string()];
4156            if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4157                parts.push(format_kv("id", id));
4158            }
4159            if let Some(collection) = attrs.get("collection").and_then(serde_json::Value::as_str) {
4160                parts.push(format_kv("collection", collection));
4161            }
4162            if let Some(occurrence_key) = attrs
4163                .get("occurrenceKey")
4164                .and_then(serde_json::Value::as_str)
4165            {
4166                parts.push(format_kv("occurrenceKey", occurrence_key));
4167            }
4168            if let Some(height_str) = attrs.get("height").and_then(fmt_numeric_attr) {
4169                parts.push(format!("height={height_str}"));
4170            }
4171            if let Some(width_str) = attrs.get("width").and_then(fmt_numeric_attr) {
4172                parts.push(format!("width={width_str}"));
4173            }
4174            maybe_push_local_id(attrs, &mut parts, opts);
4175            // Encode mediaSingle layout/width/widthType if non-default
4176            if let Some(p_attrs) = parent_attrs {
4177                if let Some(layout) = p_attrs.get("layout").and_then(serde_json::Value::as_str) {
4178                    if layout != "center" {
4179                        parts.push(format!("layout={layout}"));
4180                    }
4181                }
4182                if let Some(ms_width_str) = p_attrs.get("width").and_then(fmt_numeric_attr) {
4183                    parts.push(format!("mediaWidth={ms_width_str}"));
4184                }
4185                if let Some(wt) = p_attrs.get("widthType").and_then(serde_json::Value::as_str) {
4186                    parts.push(format!("widthType={wt}"));
4187                }
4188                if let Some(mode) = p_attrs.get("mode").and_then(serde_json::Value::as_str) {
4189                    parts.push(format!("mode={mode}"));
4190                }
4191            }
4192            push_border_mark_attrs(&node.marks, &mut parts);
4193            output.push_str(&format!("{{{}}}", parts.join(" ")));
4194        } else {
4195            // External image
4196            let url = attrs
4197                .get("url")
4198                .and_then(serde_json::Value::as_str)
4199                .unwrap_or("");
4200            output.push_str(&format!("![{alt}]({url})"));
4201
4202            // Emit {layout=... width=... widthType=... mode=... localId=...} if non-default attrs present
4203            {
4204                let mut parts = Vec::new();
4205                if let Some(p_attrs) = parent_attrs {
4206                    let layout = p_attrs.get("layout").and_then(serde_json::Value::as_str);
4207                    let width_str = p_attrs.get("width").and_then(fmt_numeric_attr);
4208                    let width_type = p_attrs.get("widthType").and_then(serde_json::Value::as_str);
4209                    if let Some(l) = layout {
4210                        if l != "center" {
4211                            parts.push(format!("layout={l}"));
4212                        }
4213                    }
4214                    if let Some(w) = width_str {
4215                        parts.push(format!("width={w}"));
4216                    }
4217                    if let Some(wt) = width_type {
4218                        parts.push(format!("widthType={wt}"));
4219                    }
4220                    if let Some(mode) = p_attrs.get("mode").and_then(serde_json::Value::as_str) {
4221                        parts.push(format!("mode={mode}"));
4222                    }
4223                }
4224                maybe_push_local_id(attrs, &mut parts, opts);
4225                push_border_mark_attrs(&node.marks, &mut parts);
4226                if !parts.is_empty() {
4227                    output.push_str(&format!("{{{}}}", parts.join(" ")));
4228                }
4229            }
4230        }
4231
4232        output.push('\n');
4233    }
4234}
4235
4236/// Renders inline content (text nodes with marks) from a block node's children.
4237fn render_inline_content(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4238    if let Some(ref content) = node.content {
4239        for child in content {
4240            render_inline_node(child, output, opts);
4241        }
4242    }
4243}
4244
4245/// Renders a single inline ADF node to markdown.
4246fn render_inline_node(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4247    match node.node_type.as_str() {
4248        "text" => {
4249            let text = node.text.as_deref().unwrap_or("");
4250            let marks = node.marks.as_deref().unwrap_or(&[]);
4251            let has_code = marks.iter().any(|m| m.mark_type == "code");
4252            // Issue #477: Escape literal backslashes before the newline
4253            // encoding below so they are not consumed as JFM escape
4254            // sequences on round-trip.  Code marks emit content verbatim,
4255            // so backslash escaping is skipped for them.
4256            let owned;
4257            let text = if !has_code {
4258                owned = text.replace('\\', "\\\\");
4259                owned.as_str()
4260            } else {
4261                text
4262            };
4263            // Issue #454: A literal newline inside a text node is escaped
4264            // as the two-character sequence `\n` so it survives round-trip
4265            // as a single text node instead of splitting into paragraphs or
4266            // being converted to hardBreak nodes.
4267            let owned_nl;
4268            let text = if text.contains('\n') {
4269                owned_nl = text.replace('\n', "\\n");
4270                owned_nl.as_str()
4271            } else {
4272                text
4273            };
4274            // Issue #510: Two or more trailing spaces at the end of a text
4275            // node would be misinterpreted as a hardBreak marker on
4276            // round-trip (and collapse the following paragraph).  Escape the
4277            // last space with a backslash so the parser treats it as a
4278            // literal space instead of a line-break signal.
4279            let owned_ts;
4280            let text = if !has_code && text.ends_with("  ") {
4281                let mut s = text.to_string();
4282                // Insert backslash before the final space: "foo  " → "foo \ "
4283                s.insert(s.len() - 1, '\\');
4284                owned_ts = s;
4285                owned_ts.as_str()
4286            } else {
4287                text
4288            };
4289            render_marked_text(text, marks, output);
4290        }
4291        "hardBreak" => {
4292            output.push_str("\\\n");
4293        }
4294        other => {
4295            // Issue #471: Non-text inline nodes (emoji, status, date, mention, etc.)
4296            // may carry annotation marks. Render the node body first, then wrap it
4297            // in bracketed-span syntax if annotation marks are present.
4298            let mut body = String::new();
4299            render_non_text_inline_body(other, node, &mut body, opts);
4300
4301            let annotations: Vec<&AdfMark> = node
4302                .marks
4303                .as_deref()
4304                .unwrap_or(&[])
4305                .iter()
4306                .filter(|m| m.mark_type == "annotation")
4307                .collect();
4308
4309            if annotations.is_empty() {
4310                output.push_str(&body);
4311            } else {
4312                let mut attr_parts = Vec::new();
4313                for ann in &annotations {
4314                    if let Some(ref attrs) = ann.attrs {
4315                        if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4316                            let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4317                            attr_parts.push(format!("annotation-id=\"{escaped}\""));
4318                        }
4319                        if let Some(at) = attrs
4320                            .get("annotationType")
4321                            .and_then(serde_json::Value::as_str)
4322                        {
4323                            attr_parts.push(format!("annotation-type={at}"));
4324                        }
4325                    }
4326                }
4327                output.push('[');
4328                output.push_str(&body);
4329                output.push_str("]{");
4330                output.push_str(&attr_parts.join(" "));
4331                output.push('}');
4332            }
4333        }
4334    }
4335}
4336
4337/// Renders the body of a non-text inline node (without mark wrapping).
4338fn render_non_text_inline_body(
4339    node_type: &str,
4340    node: &AdfNode,
4341    output: &mut String,
4342    opts: &RenderOptions,
4343) {
4344    match node_type {
4345        "inlineCard" => {
4346            if let Some(ref attrs) = node.attrs {
4347                if let Some(url) = attrs.get("url").and_then(serde_json::Value::as_str) {
4348                    let mut attr_parts = Vec::new();
4349                    if url_safe_in_bracket_content(url) {
4350                        output.push_str(":card[");
4351                        output.push_str(url);
4352                        output.push(']');
4353                    } else {
4354                        // URL would break `:card[URL]` parsing (e.g. contains an
4355                        // unbalanced `]` or a newline).  Fall back to quoted
4356                        // attribute form so the URL round-trips losslessly.
4357                        output.push_str(":card[]");
4358                        let escaped = url.replace('\\', "\\\\").replace('"', "\\\"");
4359                        attr_parts.push(format!("url=\"{escaped}\""));
4360                    }
4361                    maybe_push_local_id(attrs, &mut attr_parts, opts);
4362                    if !attr_parts.is_empty() {
4363                        output.push('{');
4364                        output.push_str(&attr_parts.join(" "));
4365                        output.push('}');
4366                    }
4367                }
4368            }
4369        }
4370        "emoji" => {
4371            if let Some(ref attrs) = node.attrs {
4372                if let Some(short_name) = attrs.get("shortName").and_then(serde_json::Value::as_str)
4373                {
4374                    output.push(':');
4375                    let name = short_name.strip_prefix(':').unwrap_or(short_name);
4376                    let name = name.strip_suffix(':').unwrap_or(name);
4377                    output.push_str(name);
4378                    output.push(':');
4379
4380                    let mut parts = Vec::new();
4381                    let escaped_sn = short_name.replace('\\', "\\\\").replace('"', "\\\"");
4382                    parts.push(format!("shortName=\"{escaped_sn}\""));
4383                    if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4384                        let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4385                        parts.push(format!("id=\"{escaped}\""));
4386                    }
4387                    if let Some(text) = attrs.get("text").and_then(serde_json::Value::as_str) {
4388                        let escaped = text.replace('\\', "\\\\").replace('"', "\\\"");
4389                        parts.push(format!("text=\"{escaped}\""));
4390                    }
4391                    maybe_push_local_id(attrs, &mut parts, opts);
4392                    output.push('{');
4393                    output.push_str(&parts.join(" "));
4394                    output.push('}');
4395                }
4396            }
4397        }
4398        "status" => {
4399            if let Some(ref attrs) = node.attrs {
4400                let text = attrs
4401                    .get("text")
4402                    .and_then(serde_json::Value::as_str)
4403                    .unwrap_or("");
4404                let color = attrs
4405                    .get("color")
4406                    .and_then(serde_json::Value::as_str)
4407                    .unwrap_or("neutral");
4408                let mut attr_parts = vec![format!("color={color}")];
4409                if let Some(style) = attrs.get("style").and_then(serde_json::Value::as_str) {
4410                    attr_parts.push(format!("style={style}"));
4411                }
4412                maybe_push_local_id(attrs, &mut attr_parts, opts);
4413                output.push_str(&format!(":status[{text}]{{{}}}", attr_parts.join(" ")));
4414            }
4415        }
4416        "date" => {
4417            if let Some(ref attrs) = node.attrs {
4418                if let Some(timestamp) = attrs.get("timestamp").and_then(serde_json::Value::as_str)
4419                {
4420                    let display = epoch_ms_to_iso_date(timestamp);
4421                    let mut attr_parts = vec![format!("timestamp={timestamp}")];
4422                    maybe_push_local_id(attrs, &mut attr_parts, opts);
4423                    output.push_str(&format!(":date[{display}]{{{}}}", attr_parts.join(" ")));
4424                }
4425            }
4426        }
4427        "mention" => {
4428            if let Some(ref attrs) = node.attrs {
4429                let id = attrs
4430                    .get("id")
4431                    .and_then(serde_json::Value::as_str)
4432                    .unwrap_or("");
4433                let text = attrs
4434                    .get("text")
4435                    .and_then(serde_json::Value::as_str)
4436                    .unwrap_or("");
4437                let mut attr_parts = vec![format!("id={id}")];
4438                if let Some(ut) = attrs.get("userType").and_then(serde_json::Value::as_str) {
4439                    attr_parts.push(format!("userType={ut}"));
4440                }
4441                if let Some(al) = attrs.get("accessLevel").and_then(serde_json::Value::as_str) {
4442                    attr_parts.push(format!("accessLevel={al}"));
4443                }
4444                maybe_push_local_id(attrs, &mut attr_parts, opts);
4445                output.push_str(&format!(":mention[{text}]{{{}}}", attr_parts.join(" ")));
4446            }
4447        }
4448        "placeholder" => {
4449            if let Some(ref attrs) = node.attrs {
4450                let text = attrs
4451                    .get("text")
4452                    .and_then(serde_json::Value::as_str)
4453                    .unwrap_or("");
4454                output.push_str(&format!(":placeholder[{text}]"));
4455            }
4456        }
4457        "inlineExtension" => {
4458            if let Some(ref attrs) = node.attrs {
4459                let ext_type = attrs
4460                    .get("extensionType")
4461                    .and_then(serde_json::Value::as_str)
4462                    .unwrap_or("");
4463                let ext_key = attrs
4464                    .get("extensionKey")
4465                    .and_then(serde_json::Value::as_str)
4466                    .unwrap_or("");
4467                let fallback = node.text.as_deref().unwrap_or("");
4468                output.push_str(&format!(
4469                    ":extension[{fallback}]{{type={ext_type} key={ext_key}}}"
4470                ));
4471            }
4472        }
4473        "mediaInline" => {
4474            if let Some(ref attrs) = node.attrs {
4475                let mut attr_parts = Vec::new();
4476                if let Some(media_type) = attrs.get("type").and_then(serde_json::Value::as_str) {
4477                    attr_parts.push(format_kv("type", media_type));
4478                }
4479                if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4480                    attr_parts.push(format_kv("id", id));
4481                }
4482                if let Some(collection) =
4483                    attrs.get("collection").and_then(serde_json::Value::as_str)
4484                {
4485                    attr_parts.push(format_kv("collection", collection));
4486                }
4487                if let Some(url) = attrs.get("url").and_then(serde_json::Value::as_str) {
4488                    attr_parts.push(format_kv("url", url));
4489                }
4490                if let Some(alt) = attrs.get("alt").and_then(serde_json::Value::as_str) {
4491                    attr_parts.push(format_kv("alt", alt));
4492                }
4493                if let Some(width) = attrs.get("width").and_then(serde_json::Value::as_u64) {
4494                    attr_parts.push(format!("width={width}"));
4495                }
4496                if let Some(height) = attrs.get("height").and_then(serde_json::Value::as_u64) {
4497                    attr_parts.push(format!("height={height}"));
4498                }
4499                maybe_push_local_id(attrs, &mut attr_parts, opts);
4500                output.push_str(&format!(":media-inline[]{{{}}}", attr_parts.join(" ")));
4501            }
4502        }
4503        _ => {
4504            output.push_str(&format!("<!-- unsupported inline: {} -->", node.node_type));
4505        }
4506    }
4507}
4508
4509/// Renders text with ADF marks applied as markdown syntax.
4510///
4511/// Mark ordering is preserved by walking the marks array in order and emitting
4512/// one wrapper per mark (outermost first, innermost last).  The resulting
4513/// markdown round-trips back to the original mark sequence because the parser
4514/// reconstructs marks outside-in from the nested delimiter structure.
4515///
4516/// When both `strong` and `em` are present, em is rendered with `_` instead of
4517/// `*` to avoid ambiguity (e.g., `_**text**_` rather than `***text***`).  The
4518/// single exception is `[strong, em]` (exactly those two marks in that order),
4519/// which is rendered as `***text***` to preserve the familiar compact form;
4520/// the parser's triple-delimiter rule round-trips it back to `[strong, em]`.
4521fn render_marked_text(text: &str, marks: &[AdfMark], output: &mut String) {
4522    if marks.iter().any(|m| m.mark_type == "code") {
4523        render_code_marked_text(text, marks, output);
4524        return;
4525    }
4526
4527    let has_link = marks.iter().any(|m| m.mark_type == "link");
4528    let has_strong = marks.iter().any(|m| m.mark_type == "strong");
4529    let has_em = marks.iter().any(|m| m.mark_type == "em");
4530
4531    // Compact form for the common [strong, em] case: ***text***.  em is
4532    // rendered with `*` here (as part of the `***` triple delimiter), so
4533    // underscores in the content don't need escaping.
4534    if marks.len() == 2 && marks[0].mark_type == "strong" && marks[1].mark_type == "em" {
4535        let escaped = escape_emphasis_markers(text);
4536        let escaped = escape_emoji_shortcodes(&escaped);
4537        let escaped = escape_backticks(&escaped);
4538        let escaped = escape_bare_urls(&escaped);
4539        output.push_str("***");
4540        output.push_str(&escaped);
4541        output.push_str("***");
4542        return;
4543    }
4544
4545    // When both strong and em are present (in any order), em uses `_` instead
4546    // of `*` to avoid the `***` triple-delimiter ambiguity.  Otherwise em uses
4547    // `*`, which sidesteps intraword-underscore pitfalls for plain em text.
4548    let em_delim = if has_strong && has_em { "_" } else { "*" };
4549
4550    // Text must also escape `_` when em renders as `_..._` — otherwise any
4551    // underscore in the content would close the emphasis span early.
4552    let escaped = if em_delim == "_" {
4553        escape_emphasis_markers_with_underscore(text)
4554    } else {
4555        escape_emphasis_markers(text)
4556    };
4557    let escaped = escape_emoji_shortcodes(&escaped);
4558    let escaped = escape_backticks(&escaped);
4559    // Always escape bare URLs so they are not re-parsed as `inlineCard`
4560    // nodes on round-trip.  When the text carries a link mark, also escape
4561    // `[` and `]` so they do not terminate the enclosing `[…]` link syntax
4562    // (issue #493).  Escaping bare URLs inside link text additionally
4563    // prevents `\[`/`\]` escapes from leaking through the URL-as-link-text
4564    // fast path and from corrupting an auto-detected bare URL inside the
4565    // link display text (issue #551).
4566    let escaped = escape_bare_urls(&escaped);
4567    let escaped = if has_link {
4568        escape_link_brackets(&escaped)
4569    } else {
4570        escaped
4571    };
4572
4573    // Collect (open, close) wrappers in mark order, outermost first.  Consecutive
4574    // span-attr or bracketed-span marks that happen to be in the parser's
4575    // canonical order (so the merged wrapper parses back to the same mark
4576    // sequence) are merged into a single wrapper; otherwise each mark gets its
4577    // own nested wrapper so that the mark ordering survives the round-trip.
4578    let mut wrappers: Vec<(String, String)> = Vec::new();
4579    let mut i = 0;
4580    while i < marks.len() {
4581        match marks[i].mark_type.as_str() {
4582            "em" => {
4583                wrappers.push((em_delim.to_string(), em_delim.to_string()));
4584                i += 1;
4585            }
4586            "strong" => {
4587                wrappers.push(("**".to_string(), "**".to_string()));
4588                i += 1;
4589            }
4590            "strike" => {
4591                wrappers.push(("~~".to_string(), "~~".to_string()));
4592                i += 1;
4593            }
4594            "link" => {
4595                let href = link_href(&marks[i]);
4596                wrappers.push(("[".to_string(), format!("]({href})")));
4597                i += 1;
4598            }
4599            "textColor" | "backgroundColor" | "subsup" => {
4600                let start = i;
4601                while i < marks.len() && is_span_attr_mark(&marks[i].mark_type) {
4602                    i += 1;
4603                }
4604                emit_span_attr_wrappers(&marks[start..i], &mut wrappers);
4605            }
4606            "underline" | "annotation" => {
4607                let start = i;
4608                while i < marks.len() && is_bracketed_span_mark(&marks[i].mark_type) {
4609                    i += 1;
4610                }
4611                emit_bracketed_wrappers(&marks[start..i], &mut wrappers);
4612            }
4613            _ => {
4614                i += 1;
4615            }
4616        }
4617    }
4618
4619    // Apply wrappers from innermost (last) to outermost (first).
4620    let mut result = escaped;
4621    for (open, close) in wrappers.iter().rev() {
4622        result.insert_str(0, open);
4623        result.push_str(close);
4624    }
4625    output.push_str(&result);
4626}
4627
4628/// Renders a text node with a `code` mark.  Code content is emitted verbatim
4629/// inside backticks, optionally wrapped by a link and/or by `:span`/bracketed-
4630/// span carrying span-attr (`textColor`, `backgroundColor`, `subsup`) and
4631/// bracketed-span (`underline`, `annotation`) marks.  No `em`/`strong`/`strike`
4632/// formatting is applied because markdown code spans do not support nested
4633/// emphasis (issue #554: previously textColor/bg/subsup/underline were
4634/// silently dropped when combined with a code mark).
4635fn render_code_marked_text(text: &str, marks: &[AdfMark], output: &mut String) {
4636    let link_mark = marks.iter().find(|m| m.mark_type == "link");
4637
4638    let mut code_str = String::new();
4639    if let Some(link_mark) = link_mark {
4640        let href = link_href(link_mark);
4641        code_str.push('[');
4642        render_inline_code(text, &mut code_str);
4643        code_str.push_str("](");
4644        code_str.push_str(href);
4645        code_str.push(')');
4646    } else {
4647        render_inline_code(text, &mut code_str);
4648    }
4649
4650    // Build wrappers (outermost first) for span-attr and bracketed-span runs,
4651    // walking marks in order so the round-trip preserves mark ordering.
4652    let mut wrappers: Vec<(String, String)> = Vec::new();
4653    let mut i = 0;
4654    while i < marks.len() {
4655        match marks[i].mark_type.as_str() {
4656            "textColor" | "backgroundColor" | "subsup" => {
4657                let start = i;
4658                while i < marks.len() && is_span_attr_mark(&marks[i].mark_type) {
4659                    i += 1;
4660                }
4661                emit_span_attr_wrappers(&marks[start..i], &mut wrappers);
4662            }
4663            "underline" | "annotation" => {
4664                let start = i;
4665                while i < marks.len() && is_bracketed_span_mark(&marks[i].mark_type) {
4666                    i += 1;
4667                }
4668                emit_bracketed_wrappers(&marks[start..i], &mut wrappers);
4669            }
4670            _ => {
4671                i += 1;
4672            }
4673        }
4674    }
4675
4676    // Apply wrappers from innermost (last) to outermost (first).
4677    let mut result = code_str;
4678    for (open, close) in wrappers.iter().rev() {
4679        result.insert_str(0, open);
4680        result.push_str(close);
4681    }
4682    output.push_str(&result);
4683}
4684
4685/// Collects `:span` attribute fragments (color, bg, sub/sup) for a single mark.
4686fn collect_span_attr(mark: &AdfMark, attrs: &mut Vec<String>) {
4687    match mark.mark_type.as_str() {
4688        "textColor" => {
4689            if let Some(c) = mark
4690                .attrs
4691                .as_ref()
4692                .and_then(|a| a.get("color"))
4693                .and_then(serde_json::Value::as_str)
4694            {
4695                attrs.push(format!("color={c}"));
4696            }
4697        }
4698        "backgroundColor" => {
4699            if let Some(c) = mark
4700                .attrs
4701                .as_ref()
4702                .and_then(|a| a.get("color"))
4703                .and_then(serde_json::Value::as_str)
4704            {
4705                attrs.push(format!("bg={c}"));
4706            }
4707        }
4708        "subsup" => {
4709            if let Some(kind) = mark
4710                .attrs
4711                .as_ref()
4712                .and_then(|a| a.get("type"))
4713                .and_then(serde_json::Value::as_str)
4714            {
4715                attrs.push(kind.to_string());
4716            }
4717        }
4718        _ => {}
4719    }
4720}
4721
4722/// Collects bracketed-span attribute fragments for an `underline` or `annotation` mark.
4723fn collect_bracketed_attr(mark: &AdfMark, attrs: &mut Vec<String>) {
4724    match mark.mark_type.as_str() {
4725        "underline" => attrs.push("underline".to_string()),
4726        "annotation" => {
4727            if let Some(ref a) = mark.attrs {
4728                if let Some(id) = a.get("id").and_then(serde_json::Value::as_str) {
4729                    let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4730                    attrs.push(format!("annotation-id=\"{escaped}\""));
4731                }
4732                if let Some(at) = a.get("annotationType").and_then(serde_json::Value::as_str) {
4733                    attrs.push(format!("annotation-type={at}"));
4734                }
4735            }
4736        }
4737        _ => {}
4738    }
4739}
4740
4741fn is_span_attr_mark(mark_type: &str) -> bool {
4742    matches!(mark_type, "textColor" | "backgroundColor" | "subsup")
4743}
4744
4745fn is_bracketed_span_mark(mark_type: &str) -> bool {
4746    matches!(mark_type, "underline" | "annotation")
4747}
4748
4749/// Canonical ordering for span-attr marks, matching the order in which the
4750/// `:span` directive parser reads attributes (`color`, then `bg`, then
4751/// `sub`/`sup`).
4752fn span_attr_order(mark_type: &str) -> u8 {
4753    match mark_type {
4754        "textColor" => 0,
4755        "backgroundColor" => 1,
4756        "subsup" => 2,
4757        _ => u8::MAX,
4758    }
4759}
4760
4761/// Returns `true` if the run of span-attr marks is in the canonical order the
4762/// `:span` parser would produce.  A canonical run can be merged into one
4763/// `:span[...]{...}` wrapper; a non-canonical run must be split into one
4764/// nested wrapper per mark so the ordering survives the round-trip.
4765fn span_run_is_canonical(run: &[AdfMark]) -> bool {
4766    let mut prev = 0;
4767    for m in run {
4768        let order = span_attr_order(&m.mark_type);
4769        if order == u8::MAX || order < prev {
4770            return false;
4771        }
4772        prev = order;
4773    }
4774    true
4775}
4776
4777/// Returns `true` if the run of `underline`/`annotation` marks is in the
4778/// canonical order the bracketed-span parser produces (`underline` first,
4779/// followed by annotations).  A canonical run can be merged into one
4780/// `[...]{underline annotation-id=...}` wrapper.
4781fn bracketed_run_is_canonical(run: &[AdfMark]) -> bool {
4782    let mut seen_annotation = false;
4783    for m in run {
4784        match m.mark_type.as_str() {
4785            "underline" => {
4786                if seen_annotation {
4787                    return false;
4788                }
4789            }
4790            "annotation" => seen_annotation = true,
4791            _ => return false,
4792        }
4793    }
4794    true
4795}
4796
4797/// Emits one or more `:span[...]{...}` wrappers for a run of span-attr marks.
4798/// Canonical-order runs collapse into a single wrapper; non-canonical runs
4799/// emit one wrapper per mark so the order round-trips.
4800fn emit_span_attr_wrappers(run: &[AdfMark], wrappers: &mut Vec<(String, String)>) {
4801    if span_run_is_canonical(run) {
4802        let mut attrs = Vec::new();
4803        for m in run {
4804            collect_span_attr(m, &mut attrs);
4805        }
4806        wrappers.push((":span[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4807        return;
4808    }
4809    for m in run {
4810        let mut attrs = Vec::new();
4811        collect_span_attr(m, &mut attrs);
4812        wrappers.push((":span[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4813    }
4814}
4815
4816/// Emits one or more `[...]{...}` wrappers for a run of `underline`/`annotation`
4817/// marks.  Canonical-order runs collapse into a single wrapper; non-canonical
4818/// runs emit one wrapper per mark so the order round-trips.
4819fn emit_bracketed_wrappers(run: &[AdfMark], wrappers: &mut Vec<(String, String)>) {
4820    if bracketed_run_is_canonical(run) {
4821        let mut attrs = Vec::new();
4822        for m in run {
4823            collect_bracketed_attr(m, &mut attrs);
4824        }
4825        wrappers.push(("[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4826        return;
4827    }
4828    for m in run {
4829        let mut attrs = Vec::new();
4830        collect_bracketed_attr(m, &mut attrs);
4831        wrappers.push(("[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4832    }
4833}
4834
4835/// Extracts the href from a link mark.
4836fn link_href(mark: &AdfMark) -> &str {
4837    mark.attrs
4838        .as_ref()
4839        .and_then(|a| a.get("href"))
4840        .and_then(serde_json::Value::as_str)
4841        .unwrap_or("")
4842}
4843
4844#[cfg(test)]
4845#[allow(
4846    clippy::unwrap_used,
4847    clippy::expect_used,
4848    clippy::needless_update,
4849    clippy::needless_collect,
4850    duplicate_macro_attributes
4851)]
4852mod tests {
4853    use super::*;
4854
4855    // ── markdown_to_adf tests ───────────────────────────────────────
4856
4857    #[test]
4858    fn paragraph() {
4859        let doc = markdown_to_adf("Hello world").unwrap();
4860        assert_eq!(doc.content.len(), 1);
4861        assert_eq!(doc.content[0].node_type, "paragraph");
4862    }
4863
4864    #[test]
4865    fn heading_levels() {
4866        for level in 1..=6 {
4867            let hashes = "#".repeat(level);
4868            let md = format!("{hashes} Title");
4869            let doc = markdown_to_adf(&md).unwrap();
4870            assert_eq!(doc.content[0].node_type, "heading");
4871            let attrs = doc.content[0].attrs.as_ref().unwrap();
4872            assert_eq!(attrs["level"], level as u64);
4873        }
4874    }
4875
4876    #[test]
4877    fn code_block() {
4878        let md = "```rust\nfn main() {}\n```";
4879        let doc = markdown_to_adf(md).unwrap();
4880        assert_eq!(doc.content[0].node_type, "codeBlock");
4881        let attrs = doc.content[0].attrs.as_ref().unwrap();
4882        assert_eq!(attrs["language"], "rust");
4883    }
4884
4885    #[test]
4886    fn code_block_no_language() {
4887        let md = "```\nsome code\n```";
4888        let doc = markdown_to_adf(md).unwrap();
4889        assert_eq!(doc.content[0].node_type, "codeBlock");
4890        assert!(doc.content[0].attrs.is_none());
4891    }
4892
4893    #[test]
4894    fn code_block_empty_language() {
4895        let md = "```\"\"\nsome code\n```";
4896        let doc = markdown_to_adf(md).unwrap();
4897        assert_eq!(doc.content[0].node_type, "codeBlock");
4898        let attrs = doc.content[0].attrs.as_ref().unwrap();
4899        assert_eq!(attrs["language"], "");
4900    }
4901
4902    #[test]
4903    fn horizontal_rule() {
4904        let doc = markdown_to_adf("---").unwrap();
4905        assert_eq!(doc.content[0].node_type, "rule");
4906    }
4907
4908    #[test]
4909    fn horizontal_rule_stars() {
4910        let doc = markdown_to_adf("***").unwrap();
4911        assert_eq!(doc.content[0].node_type, "rule");
4912    }
4913
4914    #[test]
4915    fn blockquote() {
4916        let md = "> This is a quote\n> Second line";
4917        let doc = markdown_to_adf(md).unwrap();
4918        assert_eq!(doc.content[0].node_type, "blockquote");
4919    }
4920
4921    #[test]
4922    fn bullet_list() {
4923        let md = "- Item 1\n- Item 2\n- Item 3";
4924        let doc = markdown_to_adf(md).unwrap();
4925        assert_eq!(doc.content[0].node_type, "bulletList");
4926        let items = doc.content[0].content.as_ref().unwrap();
4927        assert_eq!(items.len(), 3);
4928    }
4929
4930    #[test]
4931    fn ordered_list() {
4932        let md = "1. First\n2. Second\n3. Third";
4933        let doc = markdown_to_adf(md).unwrap();
4934        assert_eq!(doc.content[0].node_type, "orderedList");
4935        let items = doc.content[0].content.as_ref().unwrap();
4936        assert_eq!(items.len(), 3);
4937    }
4938
4939    #[test]
4940    fn task_list() {
4941        let md = "- [ ] Todo item\n- [x] Done item";
4942        let doc = markdown_to_adf(md).unwrap();
4943        assert_eq!(doc.content[0].node_type, "taskList");
4944        let items = doc.content[0].content.as_ref().unwrap();
4945        assert_eq!(items.len(), 2);
4946        assert_eq!(items[0].node_type, "taskItem");
4947        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
4948        assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
4949    }
4950
4951    #[test]
4952    fn task_list_uppercase_x() {
4953        let md = "- [X] Done item";
4954        let doc = markdown_to_adf(md).unwrap();
4955        assert_eq!(doc.content[0].node_type, "taskList");
4956        let item = &doc.content[0].content.as_ref().unwrap()[0];
4957        assert_eq!(item.attrs.as_ref().unwrap()["state"], "DONE");
4958    }
4959
4960    /// Issue #548: an empty task marker (no trailing space) must still be
4961    /// parsed as a `taskList` rather than a `bulletList` with `[ ]` text.
4962    #[test]
4963    fn task_list_empty_todo_no_trailing_space() {
4964        let md = "- [ ]";
4965        let doc = markdown_to_adf(md).unwrap();
4966        assert_eq!(doc.content[0].node_type, "taskList");
4967        let items = doc.content[0].content.as_ref().unwrap();
4968        assert_eq!(items.len(), 1);
4969        assert_eq!(items[0].node_type, "taskItem");
4970        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
4971        assert!(items[0].content.is_none());
4972    }
4973
4974    /// Issue #548: likewise for a done checkbox with no body.
4975    #[test]
4976    fn task_list_empty_done_no_trailing_space() {
4977        let md = "- [x]\n- [X]";
4978        let doc = markdown_to_adf(md).unwrap();
4979        assert_eq!(doc.content[0].node_type, "taskList");
4980        let items = doc.content[0].content.as_ref().unwrap();
4981        assert_eq!(items.len(), 2);
4982        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DONE");
4983        assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
4984    }
4985
4986    /// Issue #548: the body of `- [ ] text` must not have a spurious leading
4987    /// space introduced by relaxing the trailing-space requirement.
4988    #[test]
4989    fn task_list_body_has_no_leading_space() {
4990        let md = "- [ ] Buy groceries";
4991        let doc = markdown_to_adf(md).unwrap();
4992        let item = &doc.content[0].content.as_ref().unwrap()[0];
4993        let text = item.content.as_ref().unwrap()[0].text.as_deref().unwrap();
4994        assert_eq!(text, "Buy groceries");
4995    }
4996
4997    /// Issue #548: round-trip from ADF with empty taskItems should preserve
4998    /// the `taskList` structure even if trailing spaces are stripped from the
4999    /// intermediate markdown (as many editors do).
5000    #[test]
5001    fn round_trip_empty_task_items_stripped_trailing_spaces() {
5002        let json = r#"{
5003            "version": 1,
5004            "type": "doc",
5005            "content": [{
5006                "type": "taskList",
5007                "attrs": {"localId": "abc"},
5008                "content": [
5009                    {"type": "taskItem", "attrs": {"localId": "def", "state": "TODO"}},
5010                    {"type": "taskItem", "attrs": {"localId": "ghi", "state": "DONE"}}
5011                ]
5012            }]
5013        }"#;
5014        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5015        let md = adf_to_markdown(&doc).unwrap();
5016        let stripped: String = md.lines().map(str::trim_end).collect::<Vec<_>>().join("\n");
5017        let parsed = markdown_to_adf(&stripped).unwrap();
5018        assert_eq!(parsed.content[0].node_type, "taskList");
5019        let items = parsed.content[0].content.as_ref().unwrap();
5020        assert_eq!(items.len(), 2);
5021        assert_eq!(items[0].node_type, "taskItem");
5022        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5023        assert_eq!(items[1].node_type, "taskItem");
5024        assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5025    }
5026
5027    #[test]
5028    fn try_parse_task_marker_accepts_bare_checkbox() {
5029        assert_eq!(try_parse_task_marker("[ ]"), Some(("TODO", "")));
5030        assert_eq!(try_parse_task_marker("[x]"), Some(("DONE", "")));
5031        assert_eq!(try_parse_task_marker("[X]"), Some(("DONE", "")));
5032        assert_eq!(try_parse_task_marker("[ ] foo"), Some(("TODO", "foo")));
5033        assert_eq!(try_parse_task_marker("[x] foo"), Some(("DONE", "foo")));
5034        assert_eq!(try_parse_task_marker("[ ]foo"), None);
5035        assert_eq!(try_parse_task_marker("[x]foo"), None);
5036        assert_eq!(try_parse_task_marker("[y] foo"), None);
5037    }
5038
5039    #[test]
5040    fn starts_with_task_marker_matches_parser() {
5041        // Anything `try_parse_task_marker` recognises must also be flagged
5042        // here so the renderer escapes it.
5043        assert!(starts_with_task_marker("[ ]"));
5044        assert!(starts_with_task_marker("[x]"));
5045        assert!(starts_with_task_marker("[X]"));
5046        assert!(starts_with_task_marker("[ ] foo"));
5047        assert!(starts_with_task_marker("[x] foo\n"));
5048        assert!(starts_with_task_marker("[ ]\n"));
5049        // No collision when the bracket is followed by non-whitespace.
5050        assert!(!starts_with_task_marker("[ ]foo"));
5051        assert!(!starts_with_task_marker("[y] foo"));
5052        assert!(!starts_with_task_marker("foo [ ] bar"));
5053        assert!(!starts_with_task_marker(""));
5054    }
5055
5056    /// Issue #548: a `bulletList` whose item starts with literal `[ ]` text
5057    /// must round-trip through markdown without being promoted to a
5058    /// `taskList`.
5059    #[test]
5060    fn round_trip_bullet_list_with_literal_checkbox_text() {
5061        let json = r#"{
5062            "version": 1,
5063            "type": "doc",
5064            "content": [{
5065                "type": "bulletList",
5066                "content": [{
5067                    "type": "listItem",
5068                    "content": [{
5069                        "type": "paragraph",
5070                        "content": [
5071                            {"type": "text", "text": "[ ] Review the "},
5072                            {"type": "text", "text": "config.yaml", "marks": [{"type": "code"}]},
5073                            {"type": "text", "text": " file"}
5074                        ]
5075                    }]
5076                }]
5077            }]
5078        }"#;
5079        let original: AdfDocument = serde_json::from_str(json).unwrap();
5080        let md = adf_to_markdown(&original).unwrap();
5081        // Renderer must escape the leading bracket.
5082        assert!(
5083            md.contains(r"- \[ ] Review the "),
5084            "rendered markdown: {md:?}"
5085        );
5086        let parsed = markdown_to_adf(&md).unwrap();
5087        assert_eq!(parsed.content[0].node_type, "bulletList");
5088        let item = &parsed.content[0].content.as_ref().unwrap()[0];
5089        assert_eq!(item.node_type, "listItem");
5090        let para = &item.content.as_ref().unwrap()[0];
5091        assert_eq!(para.node_type, "paragraph");
5092        let text_nodes = para.content.as_ref().unwrap();
5093        assert_eq!(text_nodes[0].text.as_deref().unwrap(), "[ ] Review the ");
5094        assert_eq!(text_nodes[1].text.as_deref().unwrap(), "config.yaml");
5095        assert_eq!(text_nodes[2].text.as_deref().unwrap(), " file");
5096    }
5097
5098    /// Issue #548: the same problem with a `[x]` marker.
5099    #[test]
5100    fn round_trip_bullet_list_with_literal_done_checkbox_text() {
5101        let json = r#"{
5102            "version": 1,
5103            "type": "doc",
5104            "content": [{
5105                "type": "bulletList",
5106                "content": [{
5107                    "type": "listItem",
5108                    "content": [{
5109                        "type": "paragraph",
5110                        "content": [{"type": "text", "text": "[x] not actually done"}]
5111                    }]
5112                }]
5113            }]
5114        }"#;
5115        let original: AdfDocument = serde_json::from_str(json).unwrap();
5116        let md = adf_to_markdown(&original).unwrap();
5117        assert!(md.contains(r"- \[x] "), "rendered markdown: {md:?}");
5118        let parsed = markdown_to_adf(&md).unwrap();
5119        assert_eq!(parsed.content[0].node_type, "bulletList");
5120        let item = &parsed.content[0].content.as_ref().unwrap()[0];
5121        let para = &item.content.as_ref().unwrap()[0];
5122        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5123        assert_eq!(text, "[x] not actually done");
5124    }
5125
5126    /// Issue #548: `bulletList` item whose entire content is literal `[ ]`.
5127    #[test]
5128    fn round_trip_bullet_list_with_bare_literal_checkbox() {
5129        let json = r#"{
5130            "version": 1,
5131            "type": "doc",
5132            "content": [{
5133                "type": "bulletList",
5134                "content": [{
5135                    "type": "listItem",
5136                    "content": [{
5137                        "type": "paragraph",
5138                        "content": [{"type": "text", "text": "[ ]"}]
5139                    }]
5140                }]
5141            }]
5142        }"#;
5143        let original: AdfDocument = serde_json::from_str(json).unwrap();
5144        let md = adf_to_markdown(&original).unwrap();
5145        let parsed = markdown_to_adf(&md).unwrap();
5146        assert_eq!(parsed.content[0].node_type, "bulletList");
5147        let item = &parsed.content[0].content.as_ref().unwrap()[0];
5148        let para = &item.content.as_ref().unwrap()[0];
5149        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5150        assert_eq!(text, "[ ]");
5151    }
5152
5153    /// Issue #548: a `bulletList` with a non-task `[?]` prefix should not be
5154    /// escaped — that would just produce noise.
5155    #[test]
5156    fn bullet_list_non_task_bracket_text_not_escaped() {
5157        let json = r#"{
5158            "version": 1,
5159            "type": "doc",
5160            "content": [{
5161                "type": "bulletList",
5162                "content": [{
5163                    "type": "listItem",
5164                    "content": [{
5165                        "type": "paragraph",
5166                        "content": [{"type": "text", "text": "[?] unsure"}]
5167                    }]
5168                }]
5169            }]
5170        }"#;
5171        let original: AdfDocument = serde_json::from_str(json).unwrap();
5172        let md = adf_to_markdown(&original).unwrap();
5173        assert!(!md.contains(r"\["), "should not escape: {md:?}");
5174        assert!(md.contains("- [?] unsure"), "rendered: {md:?}");
5175    }
5176
5177    /// Issue #548: nested `bulletList` items inside another `bulletList`
5178    /// must also have their literal `[ ]` text escaped.
5179    #[test]
5180    fn round_trip_nested_bullet_list_with_literal_checkbox_text() {
5181        let json = r#"{
5182            "version": 1,
5183            "type": "doc",
5184            "content": [{
5185                "type": "bulletList",
5186                "content": [{
5187                    "type": "listItem",
5188                    "content": [
5189                        {"type": "paragraph", "content": [{"type": "text", "text": "outer"}]},
5190                        {"type": "bulletList", "content": [{
5191                            "type": "listItem",
5192                            "content": [{
5193                                "type": "paragraph",
5194                                "content": [{"type": "text", "text": "[ ] inner literal"}]
5195                            }]
5196                        }]}
5197                    ]
5198                }]
5199            }]
5200        }"#;
5201        let original: AdfDocument = serde_json::from_str(json).unwrap();
5202        let md = adf_to_markdown(&original).unwrap();
5203        let parsed = markdown_to_adf(&md).unwrap();
5204        let outer = &parsed.content[0];
5205        assert_eq!(outer.node_type, "bulletList");
5206        let outer_item = &outer.content.as_ref().unwrap()[0];
5207        let inner_list = &outer_item.content.as_ref().unwrap()[1];
5208        assert_eq!(inner_list.node_type, "bulletList");
5209        let inner_item = &inner_list.content.as_ref().unwrap()[0];
5210        assert_eq!(inner_item.node_type, "listItem");
5211        let para = &inner_item.content.as_ref().unwrap()[0];
5212        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5213        assert_eq!(text, "[ ] inner literal");
5214    }
5215
5216    #[test]
5217    fn adf_task_list_to_markdown() {
5218        let doc = AdfDocument {
5219            version: 1,
5220            doc_type: "doc".to_string(),
5221            content: vec![AdfNode::task_list(vec![
5222                AdfNode::task_item(
5223                    "TODO",
5224                    vec![AdfNode::paragraph(vec![AdfNode::text("Todo")])],
5225                ),
5226                AdfNode::task_item(
5227                    "DONE",
5228                    vec![AdfNode::paragraph(vec![AdfNode::text("Done")])],
5229                ),
5230            ])],
5231        };
5232        let md = adf_to_markdown(&doc).unwrap();
5233        assert!(md.contains("- [ ] Todo"));
5234        assert!(md.contains("- [x] Done"));
5235    }
5236
5237    #[test]
5238    fn round_trip_task_list() {
5239        let md = "- [ ] Todo item\n- [x] Done item\n";
5240        let doc = markdown_to_adf(md).unwrap();
5241        let result = adf_to_markdown(&doc).unwrap();
5242        assert!(result.contains("- [ ] Todo item"));
5243        assert!(result.contains("- [x] Done item"));
5244    }
5245
5246    /// Issue #408: taskItem content with inline nodes directly (no paragraph wrapper).
5247    #[test]
5248    fn adf_task_item_unwrapped_inline_content() {
5249        // Real Confluence ADF: taskItem contains text nodes directly, no paragraph.
5250        let json = r#"{
5251            "version": 1,
5252            "type": "doc",
5253            "content": [{
5254                "type": "taskList",
5255                "attrs": {"localId": "list-001"},
5256                "content": [{
5257                    "type": "taskItem",
5258                    "attrs": {"localId": "task-001", "state": "TODO"},
5259                    "content": [{"type": "text", "text": "Do something"}]
5260                }]
5261            }]
5262        }"#;
5263        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5264        let md = adf_to_markdown(&doc).unwrap();
5265        assert!(md.contains("- [ ] Do something"), "got: {md}");
5266        assert!(!md.contains("adf-unsupported"), "got: {md}");
5267    }
5268
5269    /// Issue #408: multiple taskItems with unwrapped inline content.
5270    #[test]
5271    fn adf_task_list_multiple_unwrapped_items() {
5272        let json = r#"{
5273            "version": 1,
5274            "type": "doc",
5275            "content": [{
5276                "type": "taskList",
5277                "attrs": {"localId": "list-001"},
5278                "content": [
5279                    {
5280                        "type": "taskItem",
5281                        "attrs": {"localId": "task-001", "state": "TODO"},
5282                        "content": [{"type": "text", "text": "First task"}]
5283                    },
5284                    {
5285                        "type": "taskItem",
5286                        "attrs": {"localId": "task-002", "state": "DONE"},
5287                        "content": [{"type": "text", "text": "Second task"}]
5288                    }
5289                ]
5290            }]
5291        }"#;
5292        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5293        let md = adf_to_markdown(&doc).unwrap();
5294        assert!(md.contains("- [ ] First task"), "got: {md}");
5295        assert!(md.contains("- [x] Second task"), "got: {md}");
5296        assert!(!md.contains("adf-unsupported"), "got: {md}");
5297    }
5298
5299    /// Issue #408: unwrapped inline content with marks (bold text).
5300    #[test]
5301    fn adf_task_item_unwrapped_inline_with_marks() {
5302        let json = r#"{
5303            "version": 1,
5304            "type": "doc",
5305            "content": [{
5306                "type": "taskList",
5307                "attrs": {"localId": "list-001"},
5308                "content": [{
5309                    "type": "taskItem",
5310                    "attrs": {"localId": "task-001", "state": "TODO"},
5311                    "content": [
5312                        {"type": "text", "text": "Buy "},
5313                        {"type": "text", "text": "groceries", "marks": [{"type": "strong"}]},
5314                        {"type": "text", "text": " today"}
5315                    ]
5316                }]
5317            }]
5318        }"#;
5319        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5320        let md = adf_to_markdown(&doc).unwrap();
5321        assert!(md.contains("- [ ] Buy **groceries** today"), "got: {md}");
5322    }
5323
5324    /// Issue #408: taskItem localId is preserved for unwrapped inline content.
5325    #[test]
5326    fn adf_task_item_unwrapped_preserves_local_id() {
5327        let json = r#"{
5328            "version": 1,
5329            "type": "doc",
5330            "content": [{
5331                "type": "taskList",
5332                "attrs": {"localId": "list-001"},
5333                "content": [{
5334                    "type": "taskItem",
5335                    "attrs": {"localId": "task-001", "state": "TODO"},
5336                    "content": [{"type": "text", "text": "Do something"}]
5337                }]
5338            }]
5339        }"#;
5340        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5341        let md = adf_to_markdown(&doc).unwrap();
5342        assert!(md.contains("{localId=task-001}"), "got: {md}");
5343        assert!(md.contains("{localId=list-001}"), "got: {md}");
5344    }
5345
5346    /// Issue #408: round-trip from Confluence ADF with unwrapped taskItem content.
5347    #[test]
5348    fn round_trip_task_list_unwrapped_inline() {
5349        let json = r#"{
5350            "version": 1,
5351            "type": "doc",
5352            "content": [{
5353                "type": "taskList",
5354                "attrs": {"localId": "list-001"},
5355                "content": [
5356                    {
5357                        "type": "taskItem",
5358                        "attrs": {"localId": "task-001", "state": "TODO"},
5359                        "content": [{"type": "text", "text": "Do something"}]
5360                    },
5361                    {
5362                        "type": "taskItem",
5363                        "attrs": {"localId": "task-002", "state": "DONE"},
5364                        "content": [{"type": "text", "text": "Already done"}]
5365                    }
5366                ]
5367            }]
5368        }"#;
5369        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5370        let md = adf_to_markdown(&doc).unwrap();
5371
5372        // Round-trip: markdown back to ADF
5373        let doc2 = markdown_to_adf(&md).unwrap();
5374        assert_eq!(doc2.content[0].node_type, "taskList");
5375
5376        let items = doc2.content[0].content.as_ref().unwrap();
5377        assert_eq!(items.len(), 2);
5378        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5379        assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5380
5381        // localIds preserved
5382        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "task-001");
5383        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "task-002");
5384        assert_eq!(
5385            doc2.content[0].attrs.as_ref().unwrap()["localId"],
5386            "list-001"
5387        );
5388    }
5389
5390    /// Issue #408: taskItem with inline content followed by a nested block (sub-list).
5391    #[test]
5392    fn adf_task_item_unwrapped_inline_then_block() {
5393        let json = r#"{
5394            "version": 1,
5395            "type": "doc",
5396            "content": [{
5397                "type": "taskList",
5398                "attrs": {"localId": "list-001"},
5399                "content": [{
5400                    "type": "taskItem",
5401                    "attrs": {"localId": "task-001", "state": "TODO"},
5402                    "content": [
5403                        {"type": "text", "text": "Parent task"},
5404                        {
5405                            "type": "bulletList",
5406                            "content": [{
5407                                "type": "listItem",
5408                                "content": [{
5409                                    "type": "paragraph",
5410                                    "content": [{"type": "text", "text": "sub-item"}]
5411                                }]
5412                            }]
5413                        }
5414                    ]
5415                }]
5416            }]
5417        }"#;
5418        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5419        let md = adf_to_markdown(&doc).unwrap();
5420        assert!(md.contains("- [ ] Parent task"), "got: {md}");
5421        assert!(md.contains("  - sub-item"), "got: {md}");
5422        assert!(!md.contains("adf-unsupported"), "got: {md}");
5423    }
5424
5425    /// Issue #408: taskItem with empty content array renders without panic.
5426    #[test]
5427    fn adf_task_item_empty_content() {
5428        let json = r#"{
5429            "version": 1,
5430            "type": "doc",
5431            "content": [{
5432                "type": "taskList",
5433                "attrs": {"localId": "list-001"},
5434                "content": [{
5435                    "type": "taskItem",
5436                    "attrs": {"localId": "task-001", "state": "TODO"},
5437                    "content": []
5438                }]
5439            }]
5440        }"#;
5441        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5442        let md = adf_to_markdown(&doc).unwrap();
5443        assert!(md.contains("- [ ] "), "got: {md}");
5444        assert!(!md.contains("adf-unsupported"), "got: {md}");
5445    }
5446
5447    /// Issue #489: nested taskItem inside taskItem.content renders as indented
5448    /// task items instead of corrupting the surrounding taskList.
5449    #[test]
5450    fn adf_nested_task_item_renders_without_corruption() {
5451        let json = r#"{
5452            "type": "doc",
5453            "version": 1,
5454            "content": [{
5455                "type": "taskList",
5456                "attrs": {"localId": ""},
5457                "content": [
5458                    {
5459                        "type": "taskItem",
5460                        "attrs": {"localId": "aabbccdd-1234-5678-abcd-aabbccdd1234", "state": "TODO"},
5461                        "content": [{"type": "text", "text": "Normal task"}]
5462                    },
5463                    {
5464                        "type": "taskItem",
5465                        "attrs": {"localId": ""},
5466                        "content": [
5467                            {
5468                                "type": "taskItem",
5469                                "attrs": {"localId": "bbccddee-2345-6789-bcde-bbccddee2345", "state": "TODO"},
5470                                "content": [{"type": "text", "text": "Nested task one"}]
5471                            },
5472                            {
5473                                "type": "taskItem",
5474                                "attrs": {"localId": "ccddee11-3456-7890-cdef-ccddee113456", "state": "DONE"},
5475                                "content": [{"type": "text", "text": "Nested task two"}]
5476                            }
5477                        ]
5478                    }
5479                ]
5480            }]
5481        }"#;
5482        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5483        let md = adf_to_markdown(&doc).unwrap();
5484        // Normal task preserved
5485        assert!(md.contains("- [ ] Normal task"), "got: {md}");
5486        // Nested tasks rendered as indented task items, not adf-unsupported
5487        assert!(!md.contains("adf-unsupported"), "got: {md}");
5488        assert!(md.contains("  - [ ] Nested task one"), "got: {md}");
5489        assert!(md.contains("  - [x] Nested task two"), "got: {md}");
5490    }
5491
5492    /// Issue #489: round-trip of nested taskItem preserves data.
5493    #[test]
5494    fn round_trip_nested_task_item() {
5495        let json = r#"{
5496            "type": "doc",
5497            "version": 1,
5498            "content": [{
5499                "type": "taskList",
5500                "attrs": {"localId": ""},
5501                "content": [
5502                    {
5503                        "type": "taskItem",
5504                        "attrs": {"localId": "task-001", "state": "TODO"},
5505                        "content": [{"type": "text", "text": "Normal task"}]
5506                    },
5507                    {
5508                        "type": "taskItem",
5509                        "attrs": {"localId": ""},
5510                        "content": [
5511                            {
5512                                "type": "taskItem",
5513                                "attrs": {"localId": "task-002", "state": "TODO"},
5514                                "content": [{"type": "text", "text": "Nested one"}]
5515                            },
5516                            {
5517                                "type": "taskItem",
5518                                "attrs": {"localId": "task-003", "state": "DONE"},
5519                                "content": [{"type": "text", "text": "Nested two"}]
5520                            }
5521                        ]
5522                    }
5523                ]
5524            }]
5525        }"#;
5526        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5527        let md = adf_to_markdown(&doc).unwrap();
5528        let doc2 = markdown_to_adf(&md).unwrap();
5529
5530        // Top-level structure: taskList with 2 items
5531        assert_eq!(doc2.content[0].node_type, "taskList");
5532        let items = doc2.content[0].content.as_ref().unwrap();
5533        assert_eq!(items.len(), 2, "expected 2 top-level items, got: {items:?}");
5534
5535        // First item: normal task preserved
5536        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5537        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "task-001");
5538        let first_content = items[0].content.as_ref().unwrap();
5539        assert_eq!(first_content[0].text.as_deref(), Some("Normal task"));
5540
5541        // Second item: container taskItem — no spurious `state` attr
5542        let container = &items[1];
5543        assert_eq!(container.node_type, "taskItem");
5544        let c_attrs = container.attrs.as_ref().unwrap();
5545        assert!(
5546            c_attrs.get("state").is_none(),
5547            "container should have no state attr, got: {c_attrs:?}"
5548        );
5549
5550        // Children are bare taskItems, NOT wrapped in a taskList
5551        let container_content = container.content.as_ref().unwrap();
5552        assert_eq!(
5553            container_content.len(),
5554            2,
5555            "expected 2 bare taskItem children"
5556        );
5557        assert_eq!(container_content[0].node_type, "taskItem");
5558        assert_eq!(
5559            container_content[0].attrs.as_ref().unwrap()["state"],
5560            "TODO"
5561        );
5562        assert_eq!(
5563            container_content[0].attrs.as_ref().unwrap()["localId"],
5564            "task-002"
5565        );
5566        assert_eq!(container_content[1].node_type, "taskItem");
5567        assert_eq!(
5568            container_content[1].attrs.as_ref().unwrap()["state"],
5569            "DONE"
5570        );
5571        assert_eq!(
5572            container_content[1].attrs.as_ref().unwrap()["localId"],
5573            "task-003"
5574        );
5575    }
5576
5577    /// Issue #489: nested taskItem with localIds on both container and children.
5578    #[test]
5579    fn adf_nested_task_item_preserves_local_ids() {
5580        let json = r#"{
5581            "type": "doc",
5582            "version": 1,
5583            "content": [{
5584                "type": "taskList",
5585                "attrs": {"localId": "list-001"},
5586                "content": [{
5587                    "type": "taskItem",
5588                    "attrs": {"localId": "container-001", "state": "TODO"},
5589                    "content": [{
5590                        "type": "taskItem",
5591                        "attrs": {"localId": "child-001", "state": "DONE"},
5592                        "content": [{"type": "text", "text": "Nested child"}]
5593                    }]
5594                }]
5595            }]
5596        }"#;
5597        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5598        let md = adf_to_markdown(&doc).unwrap();
5599        // Container localId is emitted
5600        assert!(
5601            md.contains("localId=container-001"),
5602            "container localId missing: {md}"
5603        );
5604        // Child localId is emitted
5605        assert!(
5606            md.contains("localId=child-001"),
5607            "child localId missing: {md}"
5608        );
5609        assert!(!md.contains("adf-unsupported"), "got: {md}");
5610    }
5611
5612    /// Issue #489: nested taskItem content mixed with a non-taskItem block node.
5613    /// Covers the else branch in the renderer where a child is not a taskItem.
5614    #[test]
5615    fn adf_nested_task_item_mixed_with_block_node() {
5616        let json = r#"{
5617            "type": "doc",
5618            "version": 1,
5619            "content": [{
5620                "type": "taskList",
5621                "attrs": {"localId": ""},
5622                "content": [{
5623                    "type": "taskItem",
5624                    "attrs": {"localId": "", "state": "TODO"},
5625                    "content": [
5626                        {
5627                            "type": "taskItem",
5628                            "attrs": {"localId": "", "state": "TODO"},
5629                            "content": [{"type": "text", "text": "A nested task"}]
5630                        },
5631                        {
5632                            "type": "paragraph",
5633                            "content": [{"type": "text", "text": "Stray paragraph"}]
5634                        }
5635                    ]
5636                }]
5637            }]
5638        }"#;
5639        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5640        let md = adf_to_markdown(&doc).unwrap();
5641        assert!(md.contains("  - [ ] A nested task"), "got: {md}");
5642        assert!(md.contains("  Stray paragraph"), "got: {md}");
5643        assert!(!md.contains("adf-unsupported"), "got: {md}");
5644    }
5645
5646    /// Issue #489: task item with inline text AND indented sub-content.
5647    /// Covers the parser's `Some` branch when appending nested blocks to
5648    /// an existing content vec.
5649    #[test]
5650    fn task_item_with_text_and_nested_sub_content() {
5651        let md = "- [ ] Parent task\n  - [ ] Sub task\n";
5652        let doc = markdown_to_adf(md).unwrap();
5653        assert_eq!(doc.content[0].node_type, "taskList");
5654        let items = doc.content[0].content.as_ref().unwrap();
5655        // Issue #506: the nested taskList is a sibling of the taskItem,
5656        // not a child — matching ADF's canonical structure.
5657        assert_eq!(items.len(), 2, "got: {items:?}");
5658        let parent = &items[0];
5659        assert_eq!(parent.attrs.as_ref().unwrap()["state"], "TODO");
5660        let parent_content = parent.content.as_ref().unwrap();
5661        assert_eq!(parent_content[0].text.as_deref(), Some("Parent task"));
5662        // Second item: nested taskList (sibling)
5663        assert_eq!(items[1].node_type, "taskList");
5664        let nested = items[1].content.as_ref().unwrap();
5665        assert_eq!(nested.len(), 1);
5666        assert_eq!(nested[0].attrs.as_ref().unwrap()["state"], "TODO");
5667    }
5668
5669    /// Issue #489: empty task item with non-taskList sub-content (e.g. a
5670    /// paragraph).  Exercises the `None` branch when the sub-content does
5671    /// not qualify for container-unwrap.
5672    #[test]
5673    fn task_item_empty_with_non_tasklist_sub_content() {
5674        let md = "- [ ] \n  Some paragraph text\n";
5675        let doc = markdown_to_adf(md).unwrap();
5676        assert_eq!(doc.content[0].node_type, "taskList");
5677        let items = doc.content[0].content.as_ref().unwrap();
5678        assert_eq!(items.len(), 1);
5679        let item = &items[0];
5680        assert_eq!(item.attrs.as_ref().unwrap()["state"], "TODO");
5681        let content = item.content.as_ref().unwrap();
5682        // Sub-content is a paragraph (not unwrapped since it's not a taskList)
5683        assert_eq!(content[0].node_type, "paragraph");
5684    }
5685
5686    /// Issue #489: single nested taskItem (edge case — only one child).
5687    #[test]
5688    fn adf_nested_task_item_single_child() {
5689        let json = r#"{
5690            "type": "doc",
5691            "version": 1,
5692            "content": [{
5693                "type": "taskList",
5694                "attrs": {"localId": ""},
5695                "content": [{
5696                    "type": "taskItem",
5697                    "attrs": {"localId": "", "state": "TODO"},
5698                    "content": [{
5699                        "type": "taskItem",
5700                        "attrs": {"localId": "", "state": "DONE"},
5701                        "content": [{"type": "text", "text": "Only child"}]
5702                    }]
5703                }]
5704            }]
5705        }"#;
5706        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5707        let md = adf_to_markdown(&doc).unwrap();
5708        assert!(md.contains("  - [x] Only child"), "got: {md}");
5709        assert!(!md.contains("adf-unsupported"), "got: {md}");
5710    }
5711
5712    /// Issue #506: nested taskList as direct child of outer taskList is
5713    /// rendered indented so it round-trips back as taskList, not taskItem.
5714    #[test]
5715    fn adf_nested_tasklist_sibling_renders_indented() {
5716        let json = r#"{
5717            "version": 1,
5718            "type": "doc",
5719            "content": [{
5720                "type": "taskList",
5721                "attrs": {"localId": ""},
5722                "content": [
5723                    {
5724                        "type": "taskItem",
5725                        "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000001", "state": "TODO"},
5726                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task one"}]}]
5727                    },
5728                    {
5729                        "type": "taskList",
5730                        "attrs": {"localId": ""},
5731                        "content": [{
5732                            "type": "taskItem",
5733                            "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000002", "state": "TODO"},
5734                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "nested sub-task"}]}]
5735                        }]
5736                    },
5737                    {
5738                        "type": "taskItem",
5739                        "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000003", "state": "TODO"},
5740                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task two"}]}]
5741                    }
5742                ]
5743            }]
5744        }"#;
5745        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5746        let md = adf_to_markdown(&doc).unwrap();
5747        // The nested taskList should be indented under the preceding item.
5748        assert!(md.contains("- [ ] parent task one"), "got: {md}");
5749        assert!(md.contains("  - [ ] nested sub-task"), "got: {md}");
5750        assert!(md.contains("- [ ] parent task two"), "got: {md}");
5751    }
5752
5753    /// Issue #506: round-trip preserves nested taskList type.
5754    #[test]
5755    fn round_trip_nested_tasklist_preserves_type() {
5756        let json = r#"{
5757            "version": 1,
5758            "type": "doc",
5759            "content": [{
5760                "type": "taskList",
5761                "attrs": {"localId": ""},
5762                "content": [
5763                    {
5764                        "type": "taskItem",
5765                        "attrs": {"localId": "", "state": "TODO"},
5766                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task one"}]}]
5767                    },
5768                    {
5769                        "type": "taskList",
5770                        "attrs": {"localId": ""},
5771                        "content": [{
5772                            "type": "taskItem",
5773                            "attrs": {"localId": "", "state": "TODO"},
5774                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "nested sub-task"}]}]
5775                        }]
5776                    },
5777                    {
5778                        "type": "taskItem",
5779                        "attrs": {"localId": "", "state": "TODO"},
5780                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task two"}]}]
5781                    }
5782                ]
5783            }]
5784        }"#;
5785        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5786        let md = adf_to_markdown(&doc).unwrap();
5787        let rt_doc = markdown_to_adf(&md).unwrap();
5788        // The outer taskList should still be present.
5789        assert_eq!(rt_doc.content[0].node_type, "taskList");
5790        let items = rt_doc.content[0].content.as_ref().unwrap();
5791        // The nested taskList is a sibling of the taskItem nodes,
5792        // matching the original ADF structure (issue #506).
5793        assert_eq!(items.len(), 3, "got: {items:?}");
5794        assert_eq!(items[0].node_type, "taskItem");
5795        assert_eq!(
5796            items[1].node_type, "taskList",
5797            "nested taskList should survive round-trip"
5798        );
5799        assert_eq!(items[2].node_type, "taskItem");
5800        let nested_items = items[1].content.as_ref().unwrap();
5801        assert_eq!(nested_items[0].attrs.as_ref().unwrap()["state"], "TODO");
5802    }
5803
5804    /// Issue #506: nested taskList with DONE state preserves checkbox.
5805    #[test]
5806    fn adf_nested_tasklist_done_state() {
5807        let json = r#"{
5808            "version": 1,
5809            "type": "doc",
5810            "content": [{
5811                "type": "taskList",
5812                "attrs": {"localId": ""},
5813                "content": [
5814                    {
5815                        "type": "taskItem",
5816                        "attrs": {"localId": "", "state": "TODO"},
5817                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent"}]}]
5818                    },
5819                    {
5820                        "type": "taskList",
5821                        "attrs": {"localId": ""},
5822                        "content": [{
5823                            "type": "taskItem",
5824                            "attrs": {"localId": "", "state": "DONE"},
5825                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "done child"}]}]
5826                        }]
5827                    }
5828                ]
5829            }]
5830        }"#;
5831        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5832        let md = adf_to_markdown(&doc).unwrap();
5833        assert!(md.contains("  - [x] done child"), "got: {md}");
5834        // Round-trip preserves DONE state — nested taskList is a sibling.
5835        let rt_doc = markdown_to_adf(&md).unwrap();
5836        let items = rt_doc.content[0].content.as_ref().unwrap();
5837        assert_eq!(
5838            items[1].node_type, "taskList",
5839            "nested taskList should survive round-trip"
5840        );
5841        let nested_item = &items[1].content.as_ref().unwrap()[0];
5842        assert_eq!(nested_item.attrs.as_ref().unwrap()["state"], "DONE");
5843    }
5844
5845    /// Issue #506: multiple nested taskLists at the same level.
5846    #[test]
5847    fn adf_multiple_nested_tasklists() {
5848        let json = r#"{
5849            "version": 1,
5850            "type": "doc",
5851            "content": [{
5852                "type": "taskList",
5853                "attrs": {"localId": ""},
5854                "content": [
5855                    {
5856                        "type": "taskItem",
5857                        "attrs": {"localId": "", "state": "TODO"},
5858                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "first parent"}]}]
5859                    },
5860                    {
5861                        "type": "taskList",
5862                        "attrs": {"localId": ""},
5863                        "content": [{
5864                            "type": "taskItem",
5865                            "attrs": {"localId": "", "state": "TODO"},
5866                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child A"}]}]
5867                        }]
5868                    },
5869                    {
5870                        "type": "taskItem",
5871                        "attrs": {"localId": "", "state": "TODO"},
5872                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "second parent"}]}]
5873                    },
5874                    {
5875                        "type": "taskList",
5876                        "attrs": {"localId": ""},
5877                        "content": [{
5878                            "type": "taskItem",
5879                            "attrs": {"localId": "", "state": "DONE"},
5880                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child B"}]}]
5881                        }]
5882                    }
5883                ]
5884            }]
5885        }"#;
5886        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5887        let md = adf_to_markdown(&doc).unwrap();
5888        assert!(md.contains("- [ ] first parent"), "got: {md}");
5889        assert!(md.contains("  - [ ] child A"), "got: {md}");
5890        assert!(md.contains("- [ ] second parent"), "got: {md}");
5891        assert!(md.contains("  - [x] child B"), "got: {md}");
5892    }
5893
5894    /// Issue #506: second round-trip is stable (idempotent after first
5895    /// structural normalisation).
5896    #[test]
5897    fn round_trip_nested_tasklist_stable() {
5898        let json = r#"{
5899            "version": 1,
5900            "type": "doc",
5901            "content": [{
5902                "type": "taskList",
5903                "attrs": {"localId": ""},
5904                "content": [
5905                    {
5906                        "type": "taskItem",
5907                        "attrs": {"localId": "", "state": "TODO"},
5908                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent"}]}]
5909                    },
5910                    {
5911                        "type": "taskList",
5912                        "attrs": {"localId": ""},
5913                        "content": [{
5914                            "type": "taskItem",
5915                            "attrs": {"localId": "", "state": "TODO"},
5916                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child"}]}]
5917                        }]
5918                    }
5919                ]
5920            }]
5921        }"#;
5922        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5923        // First round-trip.
5924        let md1 = adf_to_markdown(&doc).unwrap();
5925        let rt1 = markdown_to_adf(&md1).unwrap();
5926        // Second round-trip.
5927        let md2 = adf_to_markdown(&rt1).unwrap();
5928        let rt2 = markdown_to_adf(&md2).unwrap();
5929        // Markdown output should be identical after first normalisation.
5930        assert_eq!(md1, md2, "markdown should be stable across round-trips");
5931        // ADF structure should also be stable.
5932        let rt1_json = serde_json::to_string(&rt1).unwrap();
5933        let rt2_json = serde_json::to_string(&rt2).unwrap();
5934        assert_eq!(
5935            rt1_json, rt2_json,
5936            "ADF should be stable across round-trips"
5937        );
5938    }
5939
5940    /// Issue #506: task item with text and mixed indented sub-content
5941    /// (taskList + non-taskList block).  Exercises the `child_nodes` branch
5942    /// where non-taskList blocks stay as children of the taskItem while
5943    /// taskLists are promoted to siblings.
5944    #[test]
5945    fn task_item_mixed_sub_content_splits_siblings() {
5946        let md = "- [ ] Parent task\n  - [ ] Sub task\n  Some paragraph\n";
5947        let doc = markdown_to_adf(md).unwrap();
5948        let items = doc.content[0].content.as_ref().unwrap();
5949        // taskItem + sibling taskList
5950        assert_eq!(items.len(), 2, "got: {items:?}");
5951        assert_eq!(items[0].node_type, "taskItem");
5952        let parent_content = items[0].content.as_ref().unwrap();
5953        // Inline text + paragraph block (the non-taskList sub-content)
5954        assert!(
5955            parent_content.iter().any(|n| n.node_type == "paragraph"),
5956            "non-taskList sub-content should stay as child: {parent_content:?}"
5957        );
5958        // Sibling taskList
5959        assert_eq!(items[1].node_type, "taskList");
5960    }
5961
5962    /// Issue #506: empty task item with mixed indented sub-content hits the
5963    /// `None` arm of the `task.content` match when promoting taskLists to
5964    /// siblings.
5965    #[test]
5966    fn empty_task_item_mixed_sub_content_none_arm() {
5967        let md = "- [ ] \n  Some paragraph\n  - [ ] Sub task\n";
5968        let doc = markdown_to_adf(md).unwrap();
5969        let items = doc.content[0].content.as_ref().unwrap();
5970        // taskItem (with paragraph child) + sibling taskList
5971        assert_eq!(items.len(), 2, "got: {items:?}");
5972        assert_eq!(items[0].node_type, "taskItem");
5973        let parent_content = items[0].content.as_ref().unwrap();
5974        assert!(
5975            parent_content.iter().any(|n| n.node_type == "paragraph"),
5976            "paragraph should be assigned to taskItem: {parent_content:?}"
5977        );
5978        assert_eq!(items[1].node_type, "taskList");
5979    }
5980
5981    /// Issue #506: task item with text and only non-taskList sub-content
5982    /// (no sibling taskLists).  Exercises the fall-through path where
5983    /// `sibling_task_lists` is empty and child_nodes are appended to
5984    /// the existing task content (Some arm).
5985    #[test]
5986    fn task_item_text_with_non_tasklist_sub_content_only() {
5987        let md = "- [ ] My task\n  Extra paragraph content\n";
5988        let doc = markdown_to_adf(md).unwrap();
5989        let items = doc.content[0].content.as_ref().unwrap();
5990        // Single taskItem — no sibling taskLists to extract.
5991        assert_eq!(items.len(), 1, "got: {items:?}");
5992        assert_eq!(items[0].node_type, "taskItem");
5993        let content = items[0].content.as_ref().unwrap();
5994        // Inline text + sub-paragraph
5995        assert!(
5996            content.iter().any(|n| n.node_type == "paragraph"),
5997            "paragraph sub-content should be a child of taskItem: {content:?}"
5998        );
5999    }
6000
6001    /// Covers the else branch in render_list_item_content where the first
6002    /// child of a list item is a block node (not paragraph, not inline).
6003    #[test]
6004    fn adf_list_item_leading_block_node() {
6005        let json = r#"{
6006            "version": 1,
6007            "type": "doc",
6008            "content": [{
6009                "type": "bulletList",
6010                "content": [{
6011                    "type": "listItem",
6012                    "content": [{
6013                        "type": "codeBlock",
6014                        "attrs": {"language": "rust"},
6015                        "content": [{"type": "text", "text": "let x = 1;"}]
6016                    }]
6017                }]
6018            }]
6019        }"#;
6020        let doc: AdfDocument = serde_json::from_str(json).unwrap();
6021        let md = adf_to_markdown(&doc).unwrap();
6022        assert!(md.contains("```rust"), "got: {md}");
6023        assert!(md.contains("let x = 1;"), "got: {md}");
6024        // Continuation lines must be indented so the block stays inside
6025        // the list item on round-trip (issue #511).
6026        for line in md.lines() {
6027            if line.starts_with("- ") {
6028                continue; // first line with list marker
6029            }
6030            if line.trim().is_empty() {
6031                continue;
6032            }
6033            assert!(
6034                line.starts_with("  "),
6035                "continuation line not indented: {line:?}"
6036            );
6037        }
6038    }
6039
6040    /// Round-trip a codeBlock inside a listItem whose content contains a
6041    /// backtick character — the exact reproducer from issue #511.
6042    #[test]
6043    fn code_block_in_list_item_backtick_roundtrip() {
6044        let json = r#"{
6045            "version": 1,
6046            "type": "doc",
6047            "content": [{
6048                "type": "bulletList",
6049                "content": [{
6050                    "type": "listItem",
6051                    "content": [{
6052                        "type": "codeBlock",
6053                        "attrs": {"language": ""},
6054                        "content": [{"type": "text", "text": "error: some value with a backtick ` at end"}]
6055                    }]
6056                }]
6057            }]
6058        }"#;
6059        let original: AdfDocument = serde_json::from_str(json).unwrap();
6060        let md = adf_to_markdown(&original).unwrap();
6061        let roundtripped = markdown_to_adf(&md).unwrap();
6062        let list = &roundtripped.content[0];
6063        assert_eq!(list.node_type, "bulletList", "top node: {}", list.node_type);
6064        let item = &list.content.as_ref().unwrap()[0];
6065        let first_child = &item.content.as_ref().unwrap()[0];
6066        assert_eq!(
6067            first_child.node_type, "codeBlock",
6068            "expected codeBlock, got: {}",
6069            first_child.node_type
6070        );
6071        let text = first_child.content.as_ref().unwrap()[0]
6072            .text
6073            .as_deref()
6074            .unwrap();
6075        assert_eq!(text, "error: some value with a backtick ` at end");
6076    }
6077
6078    /// Code block with language tag inside a list item round-trips.
6079    #[test]
6080    fn code_block_with_language_in_list_item_roundtrip() {
6081        let json = r#"{
6082            "version": 1,
6083            "type": "doc",
6084            "content": [{
6085                "type": "bulletList",
6086                "content": [{
6087                    "type": "listItem",
6088                    "content": [{
6089                        "type": "codeBlock",
6090                        "attrs": {"language": "rust"},
6091                        "content": [{"type": "text", "text": "fn main() {\n    println!(\"hello\");\n}"}]
6092                    }]
6093                }]
6094            }]
6095        }"#;
6096        let original: AdfDocument = serde_json::from_str(json).unwrap();
6097        let md = adf_to_markdown(&original).unwrap();
6098        let roundtripped = markdown_to_adf(&md).unwrap();
6099        let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
6100        let code = &item.content.as_ref().unwrap()[0];
6101        assert_eq!(code.node_type, "codeBlock");
6102        let lang = code
6103            .attrs
6104            .as_ref()
6105            .and_then(|a| a.get("language"))
6106            .and_then(serde_json::Value::as_str)
6107            .unwrap_or("");
6108        assert_eq!(lang, "rust");
6109        let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
6110        assert!(text.contains("println!"), "code content: {text}");
6111    }
6112
6113    /// Code block in an ordered list item round-trips correctly.
6114    #[test]
6115    fn code_block_in_ordered_list_item_roundtrip() {
6116        let json = r#"{
6117            "version": 1,
6118            "type": "doc",
6119            "content": [{
6120                "type": "orderedList",
6121                "attrs": {"order": 1},
6122                "content": [{
6123                    "type": "listItem",
6124                    "content": [{
6125                        "type": "codeBlock",
6126                        "attrs": {"language": ""},
6127                        "content": [{"type": "text", "text": "backtick ` here"}]
6128                    }]
6129                }]
6130            }]
6131        }"#;
6132        let original: AdfDocument = serde_json::from_str(json).unwrap();
6133        let md = adf_to_markdown(&original).unwrap();
6134        let roundtripped = markdown_to_adf(&md).unwrap();
6135        let list = &roundtripped.content[0];
6136        assert_eq!(list.node_type, "orderedList");
6137        let item = &list.content.as_ref().unwrap()[0];
6138        let code = &item.content.as_ref().unwrap()[0];
6139        assert_eq!(code.node_type, "codeBlock");
6140        let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
6141        assert_eq!(text, "backtick ` here");
6142    }
6143
6144    /// A list item with a code block followed by a paragraph round-trips.
6145    #[test]
6146    fn code_block_then_paragraph_in_list_item() {
6147        let json = r#"{
6148            "version": 1,
6149            "type": "doc",
6150            "content": [{
6151                "type": "bulletList",
6152                "content": [{
6153                    "type": "listItem",
6154                    "content": [
6155                        {
6156                            "type": "codeBlock",
6157                            "attrs": {"language": ""},
6158                            "content": [{"type": "text", "text": "code with ` backtick"}]
6159                        },
6160                        {
6161                            "type": "paragraph",
6162                            "content": [{"type": "text", "text": "description"}]
6163                        }
6164                    ]
6165                }]
6166            }]
6167        }"#;
6168        let original: AdfDocument = serde_json::from_str(json).unwrap();
6169        let md = adf_to_markdown(&original).unwrap();
6170        let roundtripped = markdown_to_adf(&md).unwrap();
6171        let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
6172        let children = item.content.as_ref().unwrap();
6173        assert_eq!(children[0].node_type, "codeBlock");
6174        assert_eq!(children[1].node_type, "paragraph");
6175    }
6176
6177    /// Multiple backticks in code block content round-trip.
6178    #[test]
6179    fn code_block_multiple_backticks_in_list_item() {
6180        let json = r#"{
6181            "version": 1,
6182            "type": "doc",
6183            "content": [{
6184                "type": "bulletList",
6185                "content": [{
6186                    "type": "listItem",
6187                    "content": [{
6188                        "type": "codeBlock",
6189                        "attrs": {"language": ""},
6190                        "content": [{"type": "text", "text": "a ` b `` c ``` d"}]
6191                    }]
6192                }]
6193            }]
6194        }"#;
6195        let original: AdfDocument = serde_json::from_str(json).unwrap();
6196        let md = adf_to_markdown(&original).unwrap();
6197        let roundtripped = markdown_to_adf(&md).unwrap();
6198        let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
6199        let code = &item.content.as_ref().unwrap()[0];
6200        assert_eq!(code.node_type, "codeBlock");
6201        let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
6202        assert_eq!(text, "a ` b `` c ``` d");
6203    }
6204
6205    /// Media as the first child of a list item with a subsequent paragraph
6206    /// exercises the media + sub_lines branch in `parse_list_item_first_line`.
6207    #[test]
6208    fn media_first_child_with_sub_content_in_list_item() {
6209        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
6210          {"type":"listItem","content":[
6211            {"type":"mediaSingle","attrs":{"layout":"center"},
6212             "content":[{"type":"media","attrs":{"type":"file","id":"img-99","collection":"col-x","height":50,"width":100}}]},
6213            {"type":"paragraph","content":[{"type":"text","text":"Caption below"}]}
6214          ]}
6215        ]}]}"#;
6216        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
6217        let md = adf_to_markdown(&doc).unwrap();
6218        let rt = markdown_to_adf(&md).unwrap();
6219        let item = &rt.content[0].content.as_ref().unwrap()[0];
6220        let children = item.content.as_ref().unwrap();
6221        assert_eq!(
6222            children.len(),
6223            2,
6224            "expected 2 children, got {}",
6225            children.len()
6226        );
6227        assert_eq!(children[0].node_type, "mediaSingle");
6228        let media = &children[0].content.as_ref().unwrap()[0];
6229        assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-99");
6230        assert_eq!(children[1].node_type, "paragraph");
6231    }
6232
6233    #[test]
6234    fn inline_bold() {
6235        let doc = markdown_to_adf("Some **bold** text").unwrap();
6236        let content = doc.content[0].content.as_ref().unwrap();
6237        assert!(content.len() >= 3);
6238        let bold_node = &content[1];
6239        assert_eq!(bold_node.text.as_deref(), Some("bold"));
6240        let marks = bold_node.marks.as_ref().unwrap();
6241        assert_eq!(marks[0].mark_type, "strong");
6242    }
6243
6244    #[test]
6245    fn inline_italic() {
6246        let doc = markdown_to_adf("Some *italic* text").unwrap();
6247        let content = doc.content[0].content.as_ref().unwrap();
6248        let italic_node = &content[1];
6249        assert_eq!(italic_node.text.as_deref(), Some("italic"));
6250        let marks = italic_node.marks.as_ref().unwrap();
6251        assert_eq!(marks[0].mark_type, "em");
6252    }
6253
6254    #[test]
6255    fn inline_code() {
6256        let doc = markdown_to_adf("Use `code` here").unwrap();
6257        let content = doc.content[0].content.as_ref().unwrap();
6258        let code_node = &content[1];
6259        assert_eq!(code_node.text.as_deref(), Some("code"));
6260        let marks = code_node.marks.as_ref().unwrap();
6261        assert_eq!(marks[0].mark_type, "code");
6262    }
6263
6264    /// Issue #578: a code-marked text with an internal backtick must be
6265    /// emitted using double-backtick delimiters so it round-trips as a
6266    /// single node rather than being split on the inner backtick.
6267    #[test]
6268    fn inline_code_with_backtick_emitted_with_double_delimiters() {
6269        let doc = AdfDocument {
6270            version: 1,
6271            doc_type: "doc".to_string(),
6272            content: vec![AdfNode::paragraph(vec![
6273                AdfNode::text("Run "),
6274                AdfNode::text_with_marks(
6275                    "ADD `custom_threshold` TEXT NOT NULL",
6276                    vec![AdfMark::code()],
6277                ),
6278                AdfNode::text(" to update the schema."),
6279            ])],
6280        };
6281        let md = adf_to_markdown(&doc).unwrap();
6282        assert!(
6283            md.contains("``ADD `custom_threshold` TEXT NOT NULL``"),
6284            "expected double-backtick delimiters, got: {md}"
6285        );
6286    }
6287
6288    /// Issue #578: double-backtick delimited code spans parse as a single
6289    /// code-marked text node that preserves the embedded single backticks.
6290    #[test]
6291    fn inline_code_double_backtick_delimiters_parse() {
6292        let doc = markdown_to_adf("Run ``ADD `custom_threshold` TEXT NOT NULL`` now").unwrap();
6293        let content = doc.content[0].content.as_ref().unwrap();
6294        assert_eq!(content.len(), 3, "content: {content:?}");
6295        let code_node = &content[1];
6296        assert_eq!(
6297            code_node.text.as_deref(),
6298            Some("ADD `custom_threshold` TEXT NOT NULL")
6299        );
6300        let marks = code_node.marks.as_ref().unwrap();
6301        assert_eq!(marks[0].mark_type, "code");
6302    }
6303
6304    /// Issue #578: the full reproducer — a code-marked text with inner
6305    /// backticks survives ADF → JFM → ADF round-trip intact.
6306    #[test]
6307    fn inline_code_with_backtick_roundtrip() {
6308        let json = r#"{
6309            "version": 1,
6310            "type": "doc",
6311            "content": [{
6312                "type": "paragraph",
6313                "content": [
6314                    {"type": "text", "text": "Run "},
6315                    {
6316                        "type": "text",
6317                        "text": "ADD `custom_threshold` TEXT NOT NULL",
6318                        "marks": [{"type": "code"}]
6319                    },
6320                    {"type": "text", "text": " to update the schema."}
6321                ]
6322            }]
6323        }"#;
6324        let original: AdfDocument = serde_json::from_str(json).unwrap();
6325        let md = adf_to_markdown(&original).unwrap();
6326        let roundtripped = markdown_to_adf(&md).unwrap();
6327        let para = &roundtripped.content[0];
6328        let children = para.content.as_ref().unwrap();
6329        assert_eq!(children.len(), 3, "expected 3 children, got: {children:?}");
6330        assert_eq!(children[0].text.as_deref(), Some("Run "));
6331        assert_eq!(
6332            children[1].text.as_deref(),
6333            Some("ADD `custom_threshold` TEXT NOT NULL")
6334        );
6335        let marks = children[1].marks.as_ref().unwrap();
6336        assert_eq!(marks.len(), 1);
6337        assert_eq!(marks[0].mark_type, "code");
6338        assert_eq!(children[2].text.as_deref(), Some(" to update the schema."));
6339    }
6340
6341    /// A code-marked text containing a run of two backticks should be
6342    /// emitted with triple-backtick delimiters and round-trip intact —
6343    /// the first line of the paragraph also starts with the fence so this
6344    /// exercises the info-string-with-backtick fence-opener rejection.
6345    #[test]
6346    fn inline_code_with_double_backtick_roundtrip() {
6347        let doc = AdfDocument {
6348            version: 1,
6349            doc_type: "doc".to_string(),
6350            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6351                "x `` y",
6352                vec![AdfMark::code()],
6353            )])],
6354        };
6355        let md = adf_to_markdown(&doc).unwrap();
6356        let roundtripped = markdown_to_adf(&md).unwrap();
6357        let content = roundtripped.content[0].content.as_ref().unwrap();
6358        assert_eq!(content.len(), 1);
6359        assert_eq!(content[0].text.as_deref(), Some("x `` y"));
6360        let marks = content[0].marks.as_ref().unwrap();
6361        assert_eq!(marks[0].mark_type, "code");
6362    }
6363
6364    /// A code-marked text that begins with a backtick must be padded on
6365    /// both sides so the CommonMark space-stripping rule reconstructs it.
6366    #[test]
6367    fn inline_code_leading_backtick_roundtrip() {
6368        let doc = AdfDocument {
6369            version: 1,
6370            doc_type: "doc".to_string(),
6371            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6372                "`start",
6373                vec![AdfMark::code()],
6374            )])],
6375        };
6376        let md = adf_to_markdown(&doc).unwrap();
6377        let roundtripped = markdown_to_adf(&md).unwrap();
6378        let content = roundtripped.content[0].content.as_ref().unwrap();
6379        assert_eq!(content[0].text.as_deref(), Some("`start"));
6380        assert_eq!(content[0].marks.as_ref().unwrap()[0].mark_type, "code");
6381    }
6382
6383    /// A code-marked text that ends with a backtick must also survive.
6384    #[test]
6385    fn inline_code_trailing_backtick_roundtrip() {
6386        let doc = AdfDocument {
6387            version: 1,
6388            doc_type: "doc".to_string(),
6389            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6390                "end`",
6391                vec![AdfMark::code()],
6392            )])],
6393        };
6394        let md = adf_to_markdown(&doc).unwrap();
6395        let roundtripped = markdown_to_adf(&md).unwrap();
6396        let content = roundtripped.content[0].content.as_ref().unwrap();
6397        assert_eq!(content[0].text.as_deref(), Some("end`"));
6398    }
6399
6400    /// Content that both begins and ends with a space (but is not all
6401    /// spaces) needs padding so the stripping rule leaves it intact.
6402    #[test]
6403    fn inline_code_space_padded_content_roundtrip() {
6404        let doc = AdfDocument {
6405            version: 1,
6406            doc_type: "doc".to_string(),
6407            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6408                " foo ",
6409                vec![AdfMark::code()],
6410            )])],
6411        };
6412        let md = adf_to_markdown(&doc).unwrap();
6413        let roundtripped = markdown_to_adf(&md).unwrap();
6414        let content = roundtripped.content[0].content.as_ref().unwrap();
6415        assert_eq!(content[0].text.as_deref(), Some(" foo "));
6416    }
6417
6418    /// All-space content must round-trip without the stripping rule
6419    /// kicking in (per CommonMark: all-space content is not stripped).
6420    #[test]
6421    fn inline_code_all_spaces_roundtrip() {
6422        let doc = AdfDocument {
6423            version: 1,
6424            doc_type: "doc".to_string(),
6425            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6426                "   ",
6427                vec![AdfMark::code()],
6428            )])],
6429        };
6430        let md = adf_to_markdown(&doc).unwrap();
6431        let roundtripped = markdown_to_adf(&md).unwrap();
6432        let content = roundtripped.content[0].content.as_ref().unwrap();
6433        assert_eq!(content[0].text.as_deref(), Some("   "));
6434    }
6435
6436    /// A code+link mark where the code text contains a backtick must also
6437    /// round-trip — verifies the link branch of code-span rendering.
6438    #[test]
6439    fn inline_code_with_link_and_backtick_roundtrip() {
6440        let doc = AdfDocument {
6441            version: 1,
6442            doc_type: "doc".to_string(),
6443            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6444                "fn `inner`",
6445                vec![AdfMark::code(), AdfMark::link("https://example.com")],
6446            )])],
6447        };
6448        let md = adf_to_markdown(&doc).unwrap();
6449        assert!(
6450            md.contains("`` fn `inner` ``"),
6451            "expected padded double-backtick delimiters inside link, got: {md}"
6452        );
6453        let roundtripped = markdown_to_adf(&md).unwrap();
6454        let content = roundtripped.content[0].content.as_ref().unwrap();
6455        assert_eq!(content[0].text.as_deref(), Some("fn `inner`"));
6456        let mark_types: Vec<&str> = content[0]
6457            .marks
6458            .as_ref()
6459            .unwrap()
6460            .iter()
6461            .map(|m| m.mark_type.as_str())
6462            .collect();
6463        assert!(mark_types.contains(&"code"));
6464        assert!(mark_types.contains(&"link"));
6465    }
6466
6467    /// Unmatched opening backticks must not be parsed as a code span.
6468    #[test]
6469    fn inline_code_unmatched_run_is_plain_text() {
6470        let doc = markdown_to_adf("foo ``bar baz").unwrap();
6471        let content = doc.content[0].content.as_ref().unwrap();
6472        assert_eq!(content.len(), 1);
6473        assert_eq!(content[0].text.as_deref(), Some("foo ``bar baz"));
6474        assert!(content[0].marks.is_none());
6475    }
6476
6477    /// Mismatched delimiter lengths must not form a code span.  Per
6478    /// CommonMark the opening 2-backtick run and the trailing 1-backtick
6479    /// run never form a valid code span and the characters stay literal.
6480    #[test]
6481    fn inline_code_mismatched_delimiters_is_plain_text() {
6482        let doc = markdown_to_adf("``foo` bar").unwrap();
6483        let content = doc.content[0].content.as_ref().unwrap();
6484        assert_eq!(content.len(), 1);
6485        assert_eq!(content[0].text.as_deref(), Some("``foo` bar"));
6486        assert!(content[0].marks.is_none());
6487    }
6488
6489    #[test]
6490    fn inline_code_delimiter_chooses_correct_length() {
6491        assert_eq!(inline_code_delimiter("no ticks"), (1, false));
6492        assert_eq!(inline_code_delimiter("one ` here"), (2, false));
6493        assert_eq!(inline_code_delimiter("two `` here"), (3, false));
6494        assert_eq!(inline_code_delimiter("three ``` here"), (4, false));
6495        assert_eq!(inline_code_delimiter("`leading"), (2, true));
6496        assert_eq!(inline_code_delimiter("trailing`"), (2, true));
6497        assert_eq!(inline_code_delimiter(" foo "), (1, true));
6498        assert_eq!(inline_code_delimiter(" "), (1, false));
6499        assert_eq!(inline_code_delimiter("   "), (1, false));
6500        assert_eq!(inline_code_delimiter(" foo"), (1, false));
6501    }
6502
6503    #[test]
6504    fn try_parse_inline_code_strips_paired_spaces() {
6505        let (end, content) = try_parse_inline_code("`` `foo` ``", 0).unwrap();
6506        assert_eq!(end, 11);
6507        assert_eq!(content, "`foo`");
6508    }
6509
6510    #[test]
6511    fn try_parse_inline_code_all_space_content_is_preserved() {
6512        let (_end, content) = try_parse_inline_code("`   `", 0).unwrap();
6513        assert_eq!(content, "   ");
6514    }
6515
6516    #[test]
6517    fn try_parse_inline_code_single_run_matches_first_close() {
6518        let (end, content) = try_parse_inline_code("`foo` tail", 0).unwrap();
6519        assert_eq!(end, 5);
6520        assert_eq!(content, "foo");
6521    }
6522
6523    #[test]
6524    fn try_parse_inline_code_no_match_returns_none() {
6525        assert!(try_parse_inline_code("``unmatched", 0).is_none());
6526        assert!(try_parse_inline_code("plain text", 0).is_none());
6527    }
6528
6529    #[test]
6530    fn is_code_fence_opener_rejects_info_with_backtick() {
6531        assert!(is_code_fence_opener("```"));
6532        assert!(is_code_fence_opener("```rust"));
6533        assert!(is_code_fence_opener("```\"\""));
6534        assert!(!is_code_fence_opener("```x `` y```"));
6535        assert!(!is_code_fence_opener("``not-enough"));
6536        assert!(!is_code_fence_opener("no fence"));
6537    }
6538
6539    #[test]
6540    fn inline_strikethrough() {
6541        let doc = markdown_to_adf("Some ~~deleted~~ text").unwrap();
6542        let content = doc.content[0].content.as_ref().unwrap();
6543        let strike_node = &content[1];
6544        assert_eq!(strike_node.text.as_deref(), Some("deleted"));
6545        let marks = strike_node.marks.as_ref().unwrap();
6546        assert_eq!(marks[0].mark_type, "strike");
6547    }
6548
6549    #[test]
6550    fn inline_link() {
6551        let doc = markdown_to_adf("Click [here](https://example.com) now").unwrap();
6552        let content = doc.content[0].content.as_ref().unwrap();
6553        let link_node = &content[1];
6554        assert_eq!(link_node.text.as_deref(), Some("here"));
6555        let marks = link_node.marks.as_ref().unwrap();
6556        assert_eq!(marks[0].mark_type, "link");
6557    }
6558
6559    #[test]
6560    fn block_image() {
6561        let md = "![Alt text](https://example.com/image.png)";
6562        let doc = markdown_to_adf(md).unwrap();
6563        assert_eq!(doc.content[0].node_type, "mediaSingle");
6564    }
6565
6566    #[test]
6567    fn table() {
6568        let md = "| A | B |\n| --- | --- |\n| 1 | 2 |";
6569        let doc = markdown_to_adf(md).unwrap();
6570        assert_eq!(doc.content[0].node_type, "table");
6571        let rows = doc.content[0].content.as_ref().unwrap();
6572        assert_eq!(rows.len(), 2); // header + 1 body row
6573    }
6574
6575    // ── adf_to_markdown tests ───────────────────────────────────────
6576
6577    #[test]
6578    fn adf_paragraph_to_markdown() {
6579        let doc = AdfDocument {
6580            version: 1,
6581            doc_type: "doc".to_string(),
6582            content: vec![AdfNode::paragraph(vec![AdfNode::text("Hello world")])],
6583        };
6584        let md = adf_to_markdown(&doc).unwrap();
6585        assert_eq!(md.trim(), "Hello world");
6586    }
6587
6588    #[test]
6589    fn adf_heading_to_markdown() {
6590        let doc = AdfDocument {
6591            version: 1,
6592            doc_type: "doc".to_string(),
6593            content: vec![AdfNode::heading(2, vec![AdfNode::text("Title")])],
6594        };
6595        let md = adf_to_markdown(&doc).unwrap();
6596        assert_eq!(md.trim(), "## Title");
6597    }
6598
6599    #[test]
6600    fn adf_bold_to_markdown() {
6601        let doc = AdfDocument {
6602            version: 1,
6603            doc_type: "doc".to_string(),
6604            content: vec![AdfNode::paragraph(vec![
6605                AdfNode::text("Normal "),
6606                AdfNode::text_with_marks("bold", vec![AdfMark::strong()]),
6607                AdfNode::text(" text"),
6608            ])],
6609        };
6610        let md = adf_to_markdown(&doc).unwrap();
6611        assert_eq!(md.trim(), "Normal **bold** text");
6612    }
6613
6614    #[test]
6615    fn adf_code_block_to_markdown() {
6616        let doc = AdfDocument {
6617            version: 1,
6618            doc_type: "doc".to_string(),
6619            content: vec![AdfNode::code_block(Some("rust"), "let x = 1;")],
6620        };
6621        let md = adf_to_markdown(&doc).unwrap();
6622        assert!(md.contains("```rust"));
6623        assert!(md.contains("let x = 1;"));
6624        assert!(md.contains("```"));
6625    }
6626
6627    #[test]
6628    fn adf_rule_to_markdown() {
6629        let doc = AdfDocument {
6630            version: 1,
6631            doc_type: "doc".to_string(),
6632            content: vec![AdfNode::rule()],
6633        };
6634        let md = adf_to_markdown(&doc).unwrap();
6635        assert!(md.contains("---"));
6636    }
6637
6638    #[test]
6639    fn adf_bullet_list_to_markdown() {
6640        let doc = AdfDocument {
6641            version: 1,
6642            doc_type: "doc".to_string(),
6643            content: vec![AdfNode::bullet_list(vec![
6644                AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("A")])]),
6645                AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("B")])]),
6646            ])],
6647        };
6648        let md = adf_to_markdown(&doc).unwrap();
6649        assert!(md.contains("- A"));
6650        assert!(md.contains("- B"));
6651    }
6652
6653    #[test]
6654    fn adf_link_to_markdown() {
6655        let doc = AdfDocument {
6656            version: 1,
6657            doc_type: "doc".to_string(),
6658            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6659                "click",
6660                vec![AdfMark::link("https://example.com")],
6661            )])],
6662        };
6663        let md = adf_to_markdown(&doc).unwrap();
6664        assert_eq!(md.trim(), "[click](https://example.com)");
6665    }
6666
6667    #[test]
6668    fn unsupported_block_preserved_as_json() {
6669        let doc = AdfDocument {
6670            version: 1,
6671            doc_type: "doc".to_string(),
6672            content: vec![AdfNode {
6673                node_type: "unknownBlock".to_string(),
6674                attrs: Some(serde_json::json!({"key": "value"})),
6675                content: None,
6676                text: None,
6677                marks: None,
6678                local_id: None,
6679                parameters: None,
6680            }],
6681        };
6682        let md = adf_to_markdown(&doc).unwrap();
6683        assert!(md.contains("```adf-unsupported"));
6684        assert!(md.contains("\"unknownBlock\""));
6685    }
6686
6687    #[test]
6688    fn unsupported_block_round_trips() {
6689        let original = AdfDocument {
6690            version: 1,
6691            doc_type: "doc".to_string(),
6692            content: vec![AdfNode {
6693                node_type: "unknownBlock".to_string(),
6694                attrs: Some(serde_json::json!({"key": "value"})),
6695                content: None,
6696                text: None,
6697                marks: None,
6698                local_id: None,
6699                parameters: None,
6700            }],
6701        };
6702        let md = adf_to_markdown(&original).unwrap();
6703        let restored = markdown_to_adf(&md).unwrap();
6704        assert_eq!(restored.content[0].node_type, "unknownBlock");
6705        assert_eq!(restored.content[0].attrs.as_ref().unwrap()["key"], "value");
6706    }
6707
6708    // ── Round-trip tests ────────────────────────────────────────────
6709
6710    #[test]
6711    fn round_trip_simple_document() {
6712        let md = "# Hello\n\nSome text with **bold** and *italic*.\n\n- Item 1\n- Item 2\n";
6713        let adf = markdown_to_adf(md).unwrap();
6714        let restored = adf_to_markdown(&adf).unwrap();
6715
6716        assert!(restored.contains("# Hello"));
6717        assert!(restored.contains("**bold**"));
6718        assert!(restored.contains("*italic*"));
6719        assert!(restored.contains("- Item 1"));
6720        assert!(restored.contains("- Item 2"));
6721    }
6722
6723    #[test]
6724    fn round_trip_code_block() {
6725        let md = "```python\nprint('hello')\n```\n";
6726        let adf = markdown_to_adf(md).unwrap();
6727        let restored = adf_to_markdown(&adf).unwrap();
6728
6729        assert!(restored.contains("```python"));
6730        assert!(restored.contains("print('hello')"));
6731    }
6732
6733    #[test]
6734    fn round_trip_code_block_no_attrs() {
6735        let adf_json = r#"{"version":1,"type":"doc","content":[
6736            {"type":"codeBlock","content":[{"type":"text","text":"plain code"}]}
6737        ]}"#;
6738        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
6739        assert!(doc.content[0].attrs.is_none());
6740        let md = adf_to_markdown(&doc).unwrap();
6741        let round_tripped = markdown_to_adf(&md).unwrap();
6742        assert!(round_tripped.content[0].attrs.is_none());
6743    }
6744
6745    #[test]
6746    fn round_trip_code_block_empty_language() {
6747        let adf_json = r#"{"version":1,"type":"doc","content":[
6748            {"type":"codeBlock","attrs":{"language":""},"content":[{"type":"text","text":"simple code block no backtick"}]}
6749        ]}"#;
6750        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
6751        let attrs = doc.content[0].attrs.as_ref().unwrap();
6752        assert_eq!(attrs["language"], "");
6753        let md = adf_to_markdown(&doc).unwrap();
6754        let round_tripped = markdown_to_adf(&md).unwrap();
6755        let rt_attrs = round_tripped.content[0].attrs.as_ref().unwrap();
6756        assert_eq!(rt_attrs["language"], "");
6757    }
6758
6759    #[test]
6760    fn round_trip_code_block_with_language() {
6761        let adf_json = r#"{"version":1,"type":"doc","content":[
6762            {"type":"codeBlock","attrs":{"language":"python"},"content":[{"type":"text","text":"print('hi')"}]}
6763        ]}"#;
6764        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
6765        let md = adf_to_markdown(&doc).unwrap();
6766        let round_tripped = markdown_to_adf(&md).unwrap();
6767        let rt_attrs = round_tripped.content[0].attrs.as_ref().unwrap();
6768        assert_eq!(rt_attrs["language"], "python");
6769    }
6770
6771    #[test]
6772    fn multiple_paragraphs() {
6773        let md = "First paragraph.\n\nSecond paragraph.\n";
6774        let adf = markdown_to_adf(md).unwrap();
6775        assert_eq!(adf.content.len(), 2);
6776        assert_eq!(adf.content[0].node_type, "paragraph");
6777        assert_eq!(adf.content[1].node_type, "paragraph");
6778    }
6779
6780    // ── Additional markdown_to_adf tests ───────────────────────────────
6781
6782    #[test]
6783    fn horizontal_rule_underscores() {
6784        let doc = markdown_to_adf("___").unwrap();
6785        assert_eq!(doc.content[0].node_type, "rule");
6786    }
6787
6788    #[test]
6789    fn not_a_horizontal_rule_too_short() {
6790        let doc = markdown_to_adf("--").unwrap();
6791        assert_eq!(doc.content[0].node_type, "paragraph");
6792    }
6793
6794    #[test]
6795    fn bullet_list_star_marker() {
6796        let md = "* Apple\n* Banana";
6797        let doc = markdown_to_adf(md).unwrap();
6798        assert_eq!(doc.content[0].node_type, "bulletList");
6799        let items = doc.content[0].content.as_ref().unwrap();
6800        assert_eq!(items.len(), 2);
6801    }
6802
6803    #[test]
6804    fn bullet_list_plus_marker() {
6805        let md = "+ One\n+ Two";
6806        let doc = markdown_to_adf(md).unwrap();
6807        assert_eq!(doc.content[0].node_type, "bulletList");
6808    }
6809
6810    #[test]
6811    fn ordered_list_non_one_start() {
6812        let md = "5. Fifth\n6. Sixth";
6813        let doc = markdown_to_adf(md).unwrap();
6814        let node = &doc.content[0];
6815        assert_eq!(node.node_type, "orderedList");
6816        let attrs = node.attrs.as_ref().unwrap();
6817        assert_eq!(attrs["order"], 5);
6818    }
6819
6820    #[test]
6821    fn ordered_list_start_at_one_omits_order_attr() {
6822        // Issue #547: order=1 is the default and must be omitted from attrs
6823        // so that ADF→JFM→ADF round-trip is byte-identical for the common
6824        // case where the source ADF has no attrs object on orderedList.
6825        let md = "1. First\n2. Second";
6826        let doc = markdown_to_adf(md).unwrap();
6827        let node = &doc.content[0];
6828        assert_eq!(node.node_type, "orderedList");
6829        assert!(
6830            node.attrs.is_none(),
6831            "attrs should be omitted when order=1, got: {:?}",
6832            node.attrs
6833        );
6834    }
6835
6836    #[test]
6837    fn blockquote_bare_marker() {
6838        // ">" with no space after
6839        let md = ">quoted text";
6840        let doc = markdown_to_adf(md).unwrap();
6841        assert_eq!(doc.content[0].node_type, "blockquote");
6842    }
6843
6844    #[test]
6845    fn image_no_alt() {
6846        let md = "![](https://example.com/img.png)";
6847        let doc = markdown_to_adf(md).unwrap();
6848        let node = &doc.content[0];
6849        assert_eq!(node.node_type, "mediaSingle");
6850        // media child should have no alt attr
6851        let media = &node.content.as_ref().unwrap()[0];
6852        let attrs = media.attrs.as_ref().unwrap();
6853        assert!(attrs.get("alt").is_none());
6854    }
6855
6856    #[test]
6857    fn image_with_alt() {
6858        let md = "![A photo](https://example.com/photo.jpg)";
6859        let doc = markdown_to_adf(md).unwrap();
6860        let media = &doc.content[0].content.as_ref().unwrap()[0];
6861        let attrs = media.attrs.as_ref().unwrap();
6862        assert_eq!(attrs["alt"], "A photo");
6863    }
6864
6865    #[test]
6866    fn table_multi_body_rows() {
6867        let md = "| H1 | H2 |\n| --- | --- |\n| a | b |\n| c | d |";
6868        let doc = markdown_to_adf(md).unwrap();
6869        let rows = doc.content[0].content.as_ref().unwrap();
6870        assert_eq!(rows.len(), 3); // header + 2 body rows
6871                                   // First row cells are tableHeader
6872        let header_cells = rows[0].content.as_ref().unwrap();
6873        assert_eq!(header_cells[0].node_type, "tableHeader");
6874        // Body row cells are tableCell
6875        let body_cells = rows[1].content.as_ref().unwrap();
6876        assert_eq!(body_cells[0].node_type, "tableCell");
6877    }
6878
6879    #[test]
6880    fn table_no_separator_is_not_table() {
6881        // Pipe characters without a separator row should not parse as table
6882        let md = "| not | a table |";
6883        let doc = markdown_to_adf(md).unwrap();
6884        assert_eq!(doc.content[0].node_type, "paragraph");
6885    }
6886
6887    #[test]
6888    fn inline_underscore_bold() {
6889        let doc = markdown_to_adf("Some __bold__ text").unwrap();
6890        let content = doc.content[0].content.as_ref().unwrap();
6891        let bold_node = &content[1];
6892        assert_eq!(bold_node.text.as_deref(), Some("bold"));
6893        let marks = bold_node.marks.as_ref().unwrap();
6894        assert_eq!(marks[0].mark_type, "strong");
6895    }
6896
6897    #[test]
6898    fn inline_underscore_italic() {
6899        let doc = markdown_to_adf("Some _italic_ text").unwrap();
6900        let content = doc.content[0].content.as_ref().unwrap();
6901        let italic_node = &content[1];
6902        assert_eq!(italic_node.text.as_deref(), Some("italic"));
6903        let marks = italic_node.marks.as_ref().unwrap();
6904        assert_eq!(marks[0].mark_type, "em");
6905    }
6906
6907    #[test]
6908    fn intraword_underscore_not_emphasis() {
6909        // Single intraword underscore pair: do_something_useful
6910        let doc = markdown_to_adf("call do_something_useful now").unwrap();
6911        let content = doc.content[0].content.as_ref().unwrap();
6912        assert_eq!(content.len(), 1, "should be a single text node");
6913        assert_eq!(
6914            content[0].text.as_deref(),
6915            Some("call do_something_useful now")
6916        );
6917        assert!(content[0].marks.is_none());
6918    }
6919
6920    #[test]
6921    fn intraword_underscore_multiple() {
6922        // Multiple intraword underscores: a_b_c_d
6923        let doc = markdown_to_adf("use a_b_c_d here").unwrap();
6924        let content = doc.content[0].content.as_ref().unwrap();
6925        assert_eq!(content.len(), 1);
6926        assert_eq!(content[0].text.as_deref(), Some("use a_b_c_d here"));
6927        assert!(content[0].marks.is_none());
6928    }
6929
6930    #[test]
6931    fn intraword_double_underscore_not_bold() {
6932        // Intraword double underscores: foo__bar__baz
6933        let doc = markdown_to_adf("foo__bar__baz").unwrap();
6934        let content = doc.content[0].content.as_ref().unwrap();
6935        assert_eq!(content.len(), 1);
6936        assert_eq!(content[0].text.as_deref(), Some("foo__bar__baz"));
6937        assert!(content[0].marks.is_none());
6938    }
6939
6940    #[test]
6941    fn intraword_triple_underscore_not_bold_italic() {
6942        // Intraword triple underscores: x___y___z
6943        let doc = markdown_to_adf("x___y___z").unwrap();
6944        let content = doc.content[0].content.as_ref().unwrap();
6945        assert_eq!(content.len(), 1);
6946        assert_eq!(content[0].text.as_deref(), Some("x___y___z"));
6947        assert!(content[0].marks.is_none());
6948    }
6949
6950    #[test]
6951    fn underscore_emphasis_still_works_with_spaces() {
6952        // Normal emphasis with spaces around delimiters should still work
6953        let doc = markdown_to_adf("some _italic_ here").unwrap();
6954        let content = doc.content[0].content.as_ref().unwrap();
6955        assert_eq!(content.len(), 3);
6956        assert_eq!(content[1].text.as_deref(), Some("italic"));
6957        let marks = content[1].marks.as_ref().unwrap();
6958        assert_eq!(marks[0].mark_type, "em");
6959    }
6960
6961    #[test]
6962    fn underscore_bold_still_works_with_spaces() {
6963        // Normal bold with spaces around delimiters should still work
6964        let doc = markdown_to_adf("some __bold__ here").unwrap();
6965        let content = doc.content[0].content.as_ref().unwrap();
6966        assert_eq!(content.len(), 3);
6967        assert_eq!(content[1].text.as_deref(), Some("bold"));
6968        let marks = content[1].marks.as_ref().unwrap();
6969        assert_eq!(marks[0].mark_type, "strong");
6970    }
6971
6972    #[test]
6973    fn intraword_underscore_closing_only() {
6974        // Opening _ is valid (preceded by space) but closing _ is intraword: _foo_bar
6975        let doc = markdown_to_adf("_foo_bar").unwrap();
6976        let content = doc.content[0].content.as_ref().unwrap();
6977        assert_eq!(content.len(), 1);
6978        assert_eq!(content[0].text.as_deref(), Some("_foo_bar"));
6979    }
6980
6981    #[test]
6982    fn intraword_double_underscore_closing_only() {
6983        // Opening __ is valid (at start) but closing __ is intraword: __foo__bar
6984        let doc = markdown_to_adf("__foo__bar").unwrap();
6985        let content = doc.content[0].content.as_ref().unwrap();
6986        assert_eq!(content.len(), 1);
6987        assert_eq!(content[0].text.as_deref(), Some("__foo__bar"));
6988    }
6989
6990    #[test]
6991    fn intraword_triple_underscore_closing_only() {
6992        // Opening ___ is valid (at start) but closing ___ is intraword: ___foo___bar
6993        let doc = markdown_to_adf("___foo___bar").unwrap();
6994        let content = doc.content[0].content.as_ref().unwrap();
6995        assert_eq!(content.len(), 1);
6996        assert_eq!(content[0].text.as_deref(), Some("___foo___bar"));
6997    }
6998
6999    #[test]
7000    fn asterisk_emphasis_unaffected_by_intraword_fix() {
7001        // Asterisks should still work for intraword emphasis (CommonMark allows this)
7002        let doc = markdown_to_adf("foo*bar*baz").unwrap();
7003        let content = doc.content[0].content.as_ref().unwrap();
7004        // Asterisks CAN be intraword emphasis per CommonMark
7005        assert!(content.len() > 1 || content[0].marks.is_some());
7006    }
7007
7008    #[test]
7009    fn intraword_underscore_at_start_of_text() {
7010        // Underscore at start of text is not intraword (no preceding alphanumeric)
7011        let doc = markdown_to_adf("_italic_ word").unwrap();
7012        let content = doc.content[0].content.as_ref().unwrap();
7013        assert_eq!(content[0].text.as_deref(), Some("italic"));
7014        let marks = content[0].marks.as_ref().unwrap();
7015        assert_eq!(marks[0].mark_type, "em");
7016    }
7017
7018    #[test]
7019    fn intraword_underscore_at_end_of_text() {
7020        // Underscore at end of text is not intraword (no following alphanumeric)
7021        let doc = markdown_to_adf("word _italic_").unwrap();
7022        let content = doc.content[0].content.as_ref().unwrap();
7023        let last = content.last().unwrap();
7024        assert_eq!(last.text.as_deref(), Some("italic"));
7025        let marks = last.marks.as_ref().unwrap();
7026        assert_eq!(marks[0].mark_type, "em");
7027    }
7028
7029    #[test]
7030    fn intraword_underscore_opening_only() {
7031        // Opening underscore is intraword but closing is not: a_b c_d
7032        // The first _ is intraword (a_b), so it shouldn't open emphasis
7033        let doc = markdown_to_adf("a_b c_d").unwrap();
7034        let content = doc.content[0].content.as_ref().unwrap();
7035        assert_eq!(content.len(), 1);
7036        assert_eq!(content[0].text.as_deref(), Some("a_b c_d"));
7037    }
7038
7039    #[test]
7040    fn intraword_underscore_roundtrip() {
7041        // The original reproducer from issue #438
7042        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"call the do_something_useful function"}]}]}"#;
7043        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7044        let jfm = adf_to_markdown(&adf).unwrap();
7045        let roundtripped = markdown_to_adf(&jfm).unwrap();
7046        let content = roundtripped.content[0].content.as_ref().unwrap();
7047        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7048        assert_eq!(
7049            content[0].text.as_deref(),
7050            Some("call the do_something_useful function")
7051        );
7052        assert!(content[0].marks.is_none());
7053    }
7054
7055    #[test]
7056    fn asterisk_emphasis_roundtrip() {
7057        // The original reproducer from issue #452
7058        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Status: *confirmed* and active"}]}]}"#;
7059        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7060        let jfm = adf_to_markdown(&adf).unwrap();
7061        let roundtripped = markdown_to_adf(&jfm).unwrap();
7062        let content = roundtripped.content[0].content.as_ref().unwrap();
7063        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7064        assert_eq!(
7065            content[0].text.as_deref(),
7066            Some("Status: *confirmed* and active")
7067        );
7068        assert!(content[0].marks.is_none());
7069    }
7070
7071    #[test]
7072    fn double_asterisk_roundtrip() {
7073        // **bold** delimiters in plain text should not become strong marks
7074        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use **kwargs in Python"}]}]}"#;
7075        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7076        let jfm = adf_to_markdown(&adf).unwrap();
7077        let roundtripped = markdown_to_adf(&jfm).unwrap();
7078        let content = roundtripped.content[0].content.as_ref().unwrap();
7079        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7080        assert_eq!(content[0].text.as_deref(), Some("Use **kwargs in Python"));
7081        assert!(content[0].marks.is_none());
7082    }
7083
7084    #[test]
7085    fn asterisk_with_em_mark_roundtrip() {
7086        // Text that already has an em mark should preserve both the mark and escaped content
7087        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a*b","marks":[{"type":"em"}]}]}]}"#;
7088        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7089        let jfm = adf_to_markdown(&adf).unwrap();
7090        let roundtripped = markdown_to_adf(&jfm).unwrap();
7091        let content = roundtripped.content[0].content.as_ref().unwrap();
7092        // Find the node with em mark
7093        let em_node = content.iter().find(|n| {
7094            n.marks
7095                .as_ref()
7096                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "em"))
7097        });
7098        assert!(em_node.is_some(), "should have an em-marked node");
7099        assert_eq!(em_node.unwrap().text.as_deref(), Some("a*b"));
7100    }
7101
7102    #[test]
7103    fn lone_asterisk_roundtrip() {
7104        // A single asterisk that cannot form emphasis should round-trip
7105        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"rating: 5 * stars"}]}]}"#;
7106        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7107        let jfm = adf_to_markdown(&adf).unwrap();
7108        let roundtripped = markdown_to_adf(&jfm).unwrap();
7109        let content = roundtripped.content[0].content.as_ref().unwrap();
7110        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7111        assert_eq!(content[0].text.as_deref(), Some("rating: 5 * stars"));
7112    }
7113
7114    #[test]
7115    fn escape_emphasis_markers_unit() {
7116        assert_eq!(escape_emphasis_markers("hello"), "hello");
7117        assert_eq!(escape_emphasis_markers("*bold*"), r"\*bold\*");
7118        assert_eq!(escape_emphasis_markers("**strong**"), r"\*\*strong\*\*");
7119        assert_eq!(escape_emphasis_markers("no stars"), "no stars");
7120        assert_eq!(escape_emphasis_markers("a * b"), r"a \* b");
7121        assert_eq!(escape_emphasis_markers(""), "");
7122    }
7123
7124    #[test]
7125    fn escape_emphasis_markers_underscore_intraword() {
7126        // Intraword underscores (alnum on both sides within the node) are
7127        // left as-is — the JFM parser will reject them as emphasis.
7128        assert_eq!(escape_emphasis_markers("foo_bar"), "foo_bar");
7129        assert_eq!(escape_emphasis_markers("a_b_c"), "a_b_c");
7130        assert_eq!(escape_emphasis_markers("foo__bar"), "foo__bar");
7131        assert_eq!(
7132            escape_emphasis_markers("call do_something_useful"),
7133            "call do_something_useful"
7134        );
7135    }
7136
7137    #[test]
7138    fn escape_emphasis_markers_underscore_at_boundary() {
7139        // Leading and trailing underscores get escaped — adjacent text nodes
7140        // could supply the alphanumeric needed to close emphasis (issue #554).
7141        assert_eq!(escape_emphasis_markers("_Action"), r"\_Action");
7142        assert_eq!(escape_emphasis_markers("Action_"), r"Action\_");
7143        assert_eq!(escape_emphasis_markers("_ "), r"\_ ");
7144        assert_eq!(escape_emphasis_markers(" _"), r" \_");
7145        assert_eq!(escape_emphasis_markers("_"), r"\_");
7146    }
7147
7148    #[test]
7149    fn escape_emphasis_markers_underscore_with_punctuation() {
7150        // Underscores adjacent to punctuation (not alphanumeric) get escaped.
7151        assert_eq!(escape_emphasis_markers("foo _bar"), r"foo \_bar");
7152        assert_eq!(escape_emphasis_markers("foo_ bar"), r"foo\_ bar");
7153        assert_eq!(escape_emphasis_markers("(_x_)"), r"(\_x\_)");
7154    }
7155
7156    #[test]
7157    fn find_unescaped_skips_backslash_escaped() {
7158        // Escaped `**` should not be found
7159        assert_eq!(find_unescaped(r"a\*\*b**", "**"), Some(6));
7160        // No unescaped match at all
7161        assert_eq!(find_unescaped(r"a\*\*b", "**"), None);
7162        // Plain match without any escaping
7163        assert_eq!(find_unescaped("a**b", "**"), Some(1));
7164        // Empty haystack
7165        assert_eq!(find_unescaped("", "**"), None);
7166    }
7167
7168    #[test]
7169    fn find_unescaped_char_skips_backslash_escaped() {
7170        // Escaped `*` should not be found
7171        assert_eq!(find_unescaped_char(r"a\*b*", b'*'), Some(4));
7172        // No unescaped match at all
7173        assert_eq!(find_unescaped_char(r"\*", b'*'), None);
7174        // Plain match
7175        assert_eq!(find_unescaped_char("a*b", b'*'), Some(1));
7176        // Empty haystack
7177        assert_eq!(find_unescaped_char("", b'*'), None);
7178    }
7179
7180    #[test]
7181    fn double_asterisk_in_strong_mark_roundtrip() {
7182        // Text with ** inside a strong mark should preserve the literal **
7183        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"call **kwargs","marks":[{"type":"strong"}]}]}]}"#;
7184        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7185        let jfm = adf_to_markdown(&adf).unwrap();
7186        let roundtripped = markdown_to_adf(&jfm).unwrap();
7187        let content = roundtripped.content[0].content.as_ref().unwrap();
7188        let strong_node = content.iter().find(|n| {
7189            n.marks
7190                .as_ref()
7191                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
7192        });
7193        assert!(strong_node.is_some(), "should have a strong-marked node");
7194        assert_eq!(strong_node.unwrap().text.as_deref(), Some("call **kwargs"));
7195    }
7196
7197    #[test]
7198    fn backtick_code_roundtrip() {
7199        // The original reproducer from issue #453
7200        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Set `max_retries` to 3 in the config"}]}]}"#;
7201        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7202        let jfm = adf_to_markdown(&adf).unwrap();
7203        let roundtripped = markdown_to_adf(&jfm).unwrap();
7204        let content = roundtripped.content[0].content.as_ref().unwrap();
7205        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7206        assert_eq!(
7207            content[0].text.as_deref(),
7208            Some("Set `max_retries` to 3 in the config")
7209        );
7210        assert!(content[0].marks.is_none());
7211    }
7212
7213    #[test]
7214    fn multiple_backtick_spans_roundtrip() {
7215        // Multiple backtick-delimited spans in a single text node
7216        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use `foo` and `bar` together"}]}]}"#;
7217        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7218        let jfm = adf_to_markdown(&adf).unwrap();
7219        let roundtripped = markdown_to_adf(&jfm).unwrap();
7220        let content = roundtripped.content[0].content.as_ref().unwrap();
7221        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7222        assert_eq!(
7223            content[0].text.as_deref(),
7224            Some("Use `foo` and `bar` together")
7225        );
7226        assert!(content[0].marks.is_none());
7227    }
7228
7229    #[test]
7230    fn lone_backtick_roundtrip() {
7231        // A single backtick that cannot form a code span should round-trip
7232        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use a ` character"}]}]}"#;
7233        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7234        let jfm = adf_to_markdown(&adf).unwrap();
7235        let roundtripped = markdown_to_adf(&jfm).unwrap();
7236        let content = roundtripped.content[0].content.as_ref().unwrap();
7237        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7238        assert_eq!(content[0].text.as_deref(), Some("Use a ` character"));
7239        assert!(content[0].marks.is_none());
7240    }
7241
7242    #[test]
7243    fn backtick_with_code_mark_roundtrip() {
7244        // Text that already has a code mark should preserve both the mark and content
7245        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"max_retries","marks":[{"type":"code"}]}]}]}"#;
7246        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7247        let jfm = adf_to_markdown(&adf).unwrap();
7248        assert_eq!(jfm.trim(), "`max_retries`");
7249        let roundtripped = markdown_to_adf(&jfm).unwrap();
7250        let content = roundtripped.content[0].content.as_ref().unwrap();
7251        let code_node = content.iter().find(|n| {
7252            n.marks
7253                .as_ref()
7254                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code"))
7255        });
7256        assert!(code_node.is_some(), "should have a code-marked node");
7257        assert_eq!(code_node.unwrap().text.as_deref(), Some("max_retries"));
7258    }
7259
7260    #[test]
7261    fn backtick_with_em_mark_roundtrip() {
7262        // Backtick inside em-marked text should preserve both
7263        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"use `cfg`","marks":[{"type":"em"}]}]}]}"#;
7264        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7265        let jfm = adf_to_markdown(&adf).unwrap();
7266        let roundtripped = markdown_to_adf(&jfm).unwrap();
7267        let content = roundtripped.content[0].content.as_ref().unwrap();
7268        let em_node = content.iter().find(|n| {
7269            n.marks
7270                .as_ref()
7271                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "em"))
7272        });
7273        assert!(em_node.is_some(), "should have an em-marked node");
7274        assert_eq!(em_node.unwrap().text.as_deref(), Some("use `cfg`"));
7275    }
7276
7277    #[test]
7278    fn escape_pipes_in_cell_unit() {
7279        assert_eq!(escape_pipes_in_cell("hello"), "hello");
7280        assert_eq!(escape_pipes_in_cell("a|b"), r"a\|b");
7281        assert_eq!(escape_pipes_in_cell("|"), r"\|");
7282        assert_eq!(escape_pipes_in_cell("|a|b|"), r"\|a\|b\|");
7283        assert_eq!(escape_pipes_in_cell(""), "");
7284        assert_eq!(
7285            escape_pipes_in_cell("`parser.decode[T|json]`"),
7286            r"`parser.decode[T\|json]`"
7287        );
7288    }
7289
7290    #[test]
7291    fn escape_backticks_unit() {
7292        assert_eq!(escape_backticks("hello"), "hello");
7293        assert_eq!(escape_backticks("`code`"), r"\`code\`");
7294        assert_eq!(escape_backticks("no ticks"), "no ticks");
7295        assert_eq!(escape_backticks("a ` b"), r"a \` b");
7296        assert_eq!(escape_backticks(""), "");
7297        assert_eq!(escape_backticks("`a` and `b`"), r"\`a\` and \`b\`");
7298    }
7299
7300    // ── Issue #477: backslash escaping ──────────────────────────────
7301
7302    #[test]
7303    fn backslash_in_text_roundtrip() {
7304        // The original reproducer from issue #477
7305        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"The path is C:\\Users\\admin\\file.txt"}]}]}"#;
7306        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7307        let jfm = adf_to_markdown(&adf).unwrap();
7308        let roundtripped = markdown_to_adf(&jfm).unwrap();
7309        let content = roundtripped.content[0].content.as_ref().unwrap();
7310        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7311        assert_eq!(
7312            content[0].text.as_deref(),
7313            Some(r"The path is C:\Users\admin\file.txt")
7314        );
7315    }
7316
7317    #[test]
7318    fn backslash_emitted_as_double_backslash() {
7319        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a\\b"}]}]}"#;
7320        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7321        let jfm = adf_to_markdown(&adf).unwrap();
7322        assert!(
7323            jfm.contains(r"a\\b"),
7324            "JFM should contain escaped backslash: {jfm}"
7325        );
7326    }
7327
7328    #[test]
7329    fn consecutive_backslashes_roundtrip() {
7330        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a\\\\b"}]}]}"#;
7331        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7332        let jfm = adf_to_markdown(&adf).unwrap();
7333        let roundtripped = markdown_to_adf(&jfm).unwrap();
7334        let content = roundtripped.content[0].content.as_ref().unwrap();
7335        assert_eq!(
7336            content[0].text.as_deref(),
7337            Some(r"a\\b"),
7338            "consecutive backslashes should survive round-trip"
7339        );
7340    }
7341
7342    #[test]
7343    fn backslash_with_strong_mark_roundtrip() {
7344        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\Users","marks":[{"type":"strong"}]}]}]}"#;
7345        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7346        let jfm = adf_to_markdown(&adf).unwrap();
7347        let roundtripped = markdown_to_adf(&jfm).unwrap();
7348        let content = roundtripped.content[0].content.as_ref().unwrap();
7349        let strong_node = content.iter().find(|n| {
7350            n.marks
7351                .as_ref()
7352                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
7353        });
7354        assert!(strong_node.is_some(), "should have a strong-marked node");
7355        assert_eq!(strong_node.unwrap().text.as_deref(), Some(r"C:\Users"));
7356    }
7357
7358    #[test]
7359    fn backslash_with_code_mark_not_escaped() {
7360        // Code marks emit content verbatim — backslashes should NOT be escaped
7361        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\Users","marks":[{"type":"code"}]}]}]}"#;
7362        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7363        let jfm = adf_to_markdown(&adf).unwrap();
7364        assert_eq!(jfm.trim(), r"`C:\Users`");
7365        let roundtripped = markdown_to_adf(&jfm).unwrap();
7366        let content = roundtripped.content[0].content.as_ref().unwrap();
7367        let code_node = content.iter().find(|n| {
7368            n.marks
7369                .as_ref()
7370                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code"))
7371        });
7372        assert!(code_node.is_some(), "should have a code-marked node");
7373        assert_eq!(code_node.unwrap().text.as_deref(), Some(r"C:\Users"));
7374    }
7375
7376    #[test]
7377    fn backslash_before_special_chars_roundtrip() {
7378        // Backslash before characters that are themselves escaped (* ` :)
7379        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"\\*not bold\\*"}]}]}"#;
7380        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7381        let jfm = adf_to_markdown(&adf).unwrap();
7382        let roundtripped = markdown_to_adf(&jfm).unwrap();
7383        let content = roundtripped.content[0].content.as_ref().unwrap();
7384        assert_eq!(
7385            content[0].text.as_deref(),
7386            Some(r"\*not bold\*"),
7387            "backslash before special char should survive round-trip"
7388        );
7389    }
7390
7391    #[test]
7392    fn backslash_and_newline_in_text_roundtrip() {
7393        // Text with both backslashes and embedded newlines
7394        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\path\nline2"}]}]}"#;
7395        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7396        let jfm = adf_to_markdown(&adf).unwrap();
7397        let roundtripped = markdown_to_adf(&jfm).unwrap();
7398        let content = roundtripped.content[0].content.as_ref().unwrap();
7399        assert_eq!(
7400            content[0].text.as_deref(),
7401            Some("C:\\path\nline2"),
7402            "backslash and newline should both survive round-trip"
7403        );
7404    }
7405
7406    #[test]
7407    fn lone_backslash_roundtrip() {
7408        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a \\ b"}]}]}"#;
7409        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7410        let jfm = adf_to_markdown(&adf).unwrap();
7411        let roundtripped = markdown_to_adf(&jfm).unwrap();
7412        let content = roundtripped.content[0].content.as_ref().unwrap();
7413        assert_eq!(content[0].text.as_deref(), Some(r"a \ b"));
7414    }
7415
7416    #[test]
7417    fn trailing_backslash_in_text_roundtrip() {
7418        // A trailing backslash in text content (not a hardBreak) should round-trip
7419        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"end\\"}]}]}"#;
7420        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7421        let jfm = adf_to_markdown(&adf).unwrap();
7422        let roundtripped = markdown_to_adf(&jfm).unwrap();
7423        let content = roundtripped.content[0].content.as_ref().unwrap();
7424        assert_eq!(
7425            content[0].text.as_deref(),
7426            Some(r"end\"),
7427            "trailing backslash should survive round-trip"
7428        );
7429    }
7430
7431    #[test]
7432    fn escape_bare_urls_unit() {
7433        assert_eq!(escape_bare_urls("hello"), "hello");
7434        assert_eq!(escape_bare_urls(""), "");
7435        assert_eq!(
7436            escape_bare_urls("https://example.com"),
7437            r"\https://example.com"
7438        );
7439        assert_eq!(
7440            escape_bare_urls("http://example.com"),
7441            r"\http://example.com"
7442        );
7443        assert_eq!(
7444            escape_bare_urls("see https://a.com and https://b.com"),
7445            r"see \https://a.com and \https://b.com"
7446        );
7447        // "http" without "://" is not a URL scheme — leave untouched
7448        assert_eq!(escape_bare_urls("http header"), "http header");
7449        assert_eq!(escape_bare_urls("https is secure"), "https is secure");
7450    }
7451
7452    #[test]
7453    fn heading_not_valid_without_space() {
7454        // "#Title" without space should be a paragraph, not heading
7455        let doc = markdown_to_adf("#Title").unwrap();
7456        assert_eq!(doc.content[0].node_type, "paragraph");
7457    }
7458
7459    #[test]
7460    fn heading_level_too_high() {
7461        // ####### (7 hashes) is not a valid heading
7462        let doc = markdown_to_adf("####### Not a heading").unwrap();
7463        assert_eq!(doc.content[0].node_type, "paragraph");
7464    }
7465
7466    #[test]
7467    fn empty_document() {
7468        let doc = markdown_to_adf("").unwrap();
7469        assert!(doc.content.is_empty());
7470    }
7471
7472    #[test]
7473    fn only_blank_lines() {
7474        let doc = markdown_to_adf("\n\n\n").unwrap();
7475        assert!(doc.content.is_empty());
7476    }
7477
7478    #[test]
7479    fn code_block_unterminated() {
7480        // Code block without closing fence
7481        let md = "```rust\nfn main() {}";
7482        let doc = markdown_to_adf(md).unwrap();
7483        assert_eq!(doc.content[0].node_type, "codeBlock");
7484    }
7485
7486    #[test]
7487    fn mixed_document() {
7488        let md = "# Title\n\nA paragraph.\n\n- Item\n\n```\ncode\n```\n\n> quote\n\n---\n\n1. numbered\n";
7489        let doc = markdown_to_adf(md).unwrap();
7490        let types: Vec<&str> = doc.content.iter().map(|n| n.node_type.as_str()).collect();
7491        assert_eq!(
7492            types,
7493            vec![
7494                "heading",
7495                "paragraph",
7496                "bulletList",
7497                "codeBlock",
7498                "blockquote",
7499                "rule",
7500                "orderedList",
7501            ]
7502        );
7503    }
7504
7505    // ── Additional adf_to_markdown tests ───────────────────────────────
7506
7507    #[test]
7508    fn adf_ordered_list_to_markdown() {
7509        let doc = AdfDocument {
7510            version: 1,
7511            doc_type: "doc".to_string(),
7512            content: vec![AdfNode::ordered_list(
7513                vec![
7514                    AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("First")])]),
7515                    AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("Second")])]),
7516                ],
7517                None,
7518            )],
7519        };
7520        let md = adf_to_markdown(&doc).unwrap();
7521        assert!(md.contains("1. First"));
7522        assert!(md.contains("2. Second"));
7523    }
7524
7525    #[test]
7526    fn adf_ordered_list_custom_start() {
7527        let doc = AdfDocument {
7528            version: 1,
7529            doc_type: "doc".to_string(),
7530            content: vec![AdfNode::ordered_list(
7531                vec![AdfNode::list_item(vec![AdfNode::paragraph(vec![
7532                    AdfNode::text("Third"),
7533                ])])],
7534                Some(3),
7535            )],
7536        };
7537        let md = adf_to_markdown(&doc).unwrap();
7538        assert!(md.contains("3. Third"));
7539    }
7540
7541    #[test]
7542    fn adf_blockquote_to_markdown() {
7543        let doc = AdfDocument {
7544            version: 1,
7545            doc_type: "doc".to_string(),
7546            content: vec![AdfNode::blockquote(vec![AdfNode::paragraph(vec![
7547                AdfNode::text("A quote"),
7548            ])])],
7549        };
7550        let md = adf_to_markdown(&doc).unwrap();
7551        assert!(md.contains("> A quote"));
7552    }
7553
7554    #[test]
7555    fn adf_table_to_markdown() {
7556        let doc = AdfDocument {
7557            version: 1,
7558            doc_type: "doc".to_string(),
7559            content: vec![AdfNode::table(vec![
7560                AdfNode::table_row(vec![
7561                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("Name")])]),
7562                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("Value")])]),
7563                ]),
7564                AdfNode::table_row(vec![
7565                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("a")])]),
7566                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("1")])]),
7567                ]),
7568            ])],
7569        };
7570        let md = adf_to_markdown(&doc).unwrap();
7571        assert!(md.contains("| Name | Value |"));
7572        assert!(md.contains("| --- | --- |"));
7573        assert!(md.contains("| a | 1 |"));
7574    }
7575
7576    #[test]
7577    fn adf_media_to_markdown() {
7578        let doc = AdfDocument {
7579            version: 1,
7580            doc_type: "doc".to_string(),
7581            content: vec![AdfNode::media_single(
7582                "https://example.com/img.png",
7583                Some("Alt"),
7584            )],
7585        };
7586        let md = adf_to_markdown(&doc).unwrap();
7587        assert!(md.contains("![Alt](https://example.com/img.png)"));
7588    }
7589
7590    #[test]
7591    fn adf_media_no_alt_to_markdown() {
7592        let doc = AdfDocument {
7593            version: 1,
7594            doc_type: "doc".to_string(),
7595            content: vec![AdfNode::media_single("https://example.com/img.png", None)],
7596        };
7597        let md = adf_to_markdown(&doc).unwrap();
7598        assert!(md.contains("![](https://example.com/img.png)"));
7599    }
7600
7601    #[test]
7602    fn adf_italic_to_markdown() {
7603        let doc = AdfDocument {
7604            version: 1,
7605            doc_type: "doc".to_string(),
7606            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7607                "emphasis",
7608                vec![AdfMark::em()],
7609            )])],
7610        };
7611        let md = adf_to_markdown(&doc).unwrap();
7612        assert_eq!(md.trim(), "*emphasis*");
7613    }
7614
7615    #[test]
7616    fn adf_strikethrough_to_markdown() {
7617        let doc = AdfDocument {
7618            version: 1,
7619            doc_type: "doc".to_string(),
7620            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7621                "deleted",
7622                vec![AdfMark::strike()],
7623            )])],
7624        };
7625        let md = adf_to_markdown(&doc).unwrap();
7626        assert_eq!(md.trim(), "~~deleted~~");
7627    }
7628
7629    #[test]
7630    fn adf_inline_code_to_markdown() {
7631        let doc = AdfDocument {
7632            version: 1,
7633            doc_type: "doc".to_string(),
7634            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7635                "code",
7636                vec![AdfMark::code()],
7637            )])],
7638        };
7639        let md = adf_to_markdown(&doc).unwrap();
7640        assert_eq!(md.trim(), "`code`");
7641    }
7642
7643    #[test]
7644    fn adf_code_with_link_to_markdown() {
7645        let doc = AdfDocument {
7646            version: 1,
7647            doc_type: "doc".to_string(),
7648            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7649                "func",
7650                vec![AdfMark::code(), AdfMark::link("https://example.com")],
7651            )])],
7652        };
7653        let md = adf_to_markdown(&doc).unwrap();
7654        assert_eq!(md.trim(), "[`func`](https://example.com)");
7655    }
7656
7657    #[test]
7658    fn adf_bold_italic_to_markdown() {
7659        let doc = AdfDocument {
7660            version: 1,
7661            doc_type: "doc".to_string(),
7662            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7663                "both",
7664                vec![AdfMark::strong(), AdfMark::em()],
7665            )])],
7666        };
7667        let md = adf_to_markdown(&doc).unwrap();
7668        assert_eq!(md.trim(), "***both***");
7669    }
7670
7671    #[test]
7672    fn adf_bold_link_to_markdown() {
7673        let doc = AdfDocument {
7674            version: 1,
7675            doc_type: "doc".to_string(),
7676            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7677                "bold link",
7678                vec![AdfMark::strong(), AdfMark::link("https://example.com")],
7679            )])],
7680        };
7681        let md = adf_to_markdown(&doc).unwrap();
7682        assert_eq!(md.trim(), "**[bold link](https://example.com)**");
7683    }
7684
7685    #[test]
7686    fn adf_strikethrough_bold_to_markdown() {
7687        let doc = AdfDocument {
7688            version: 1,
7689            doc_type: "doc".to_string(),
7690            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7691                "struck",
7692                vec![AdfMark::strike(), AdfMark::strong()],
7693            )])],
7694        };
7695        let md = adf_to_markdown(&doc).unwrap();
7696        assert_eq!(md.trim(), "~~**struck**~~");
7697    }
7698
7699    #[test]
7700    fn adf_hard_break_to_markdown() {
7701        let doc = AdfDocument {
7702            version: 1,
7703            doc_type: "doc".to_string(),
7704            content: vec![AdfNode::paragraph(vec![
7705                AdfNode::text("Line 1"),
7706                AdfNode::hard_break(),
7707                AdfNode::text("Line 2"),
7708            ])],
7709        };
7710        let md = adf_to_markdown(&doc).unwrap();
7711        assert!(md.contains("Line 1\\\n  Line 2"));
7712    }
7713
7714    #[test]
7715    #[test]
7716    fn adf_unsupported_inline_to_markdown() {
7717        let doc = AdfDocument {
7718            version: 1,
7719            doc_type: "doc".to_string(),
7720            content: vec![AdfNode::paragraph(vec![AdfNode {
7721                node_type: "unknownInline".to_string(),
7722                attrs: None,
7723                content: None,
7724                text: None,
7725                marks: None,
7726                local_id: None,
7727                parameters: None,
7728            }])],
7729        };
7730        let md = adf_to_markdown(&doc).unwrap();
7731        assert!(md.contains("<!-- unsupported inline: unknownInline -->"));
7732    }
7733
7734    // ── mediaInline tests (issue #476) ─────────────────────────────────
7735
7736    #[test]
7737    fn adf_media_inline_to_markdown() {
7738        let doc = AdfDocument {
7739            version: 1,
7740            doc_type: "doc".to_string(),
7741            content: vec![AdfNode::paragraph(vec![
7742                AdfNode::text("see "),
7743                AdfNode::media_inline(serde_json::json!({
7744                    "type": "image",
7745                    "id": "abcdef01-2345-6789-abcd-abcdef012345",
7746                    "collection": "contentId-111111",
7747                    "width": 200,
7748                    "height": 100
7749                })),
7750                AdfNode::text(" for details"),
7751            ])],
7752        };
7753        let md = adf_to_markdown(&doc).unwrap();
7754        assert!(md.contains(":media-inline[]{"), "got: {md}");
7755        assert!(md.contains("type=image"));
7756        assert!(md.contains("id=abcdef01-2345-6789-abcd-abcdef012345"));
7757        assert!(md.contains("collection=contentId-111111"));
7758        assert!(md.contains("width=200"));
7759        assert!(md.contains("height=100"));
7760        assert!(!md.contains("<!-- unsupported inline"));
7761    }
7762
7763    #[test]
7764    fn media_inline_round_trip() {
7765        let doc = AdfDocument {
7766            version: 1,
7767            doc_type: "doc".to_string(),
7768            content: vec![AdfNode::paragraph(vec![
7769                AdfNode::text("see "),
7770                AdfNode::media_inline(serde_json::json!({
7771                    "type": "image",
7772                    "id": "abcdef01-2345-6789-abcd-abcdef012345",
7773                    "collection": "contentId-111111",
7774                    "width": 200,
7775                    "height": 100
7776                })),
7777                AdfNode::text(" for details"),
7778            ])],
7779        };
7780        let md = adf_to_markdown(&doc).unwrap();
7781        let rt = markdown_to_adf(&md).unwrap();
7782
7783        let content = rt.content[0].content.as_ref().unwrap();
7784        assert_eq!(content[0].text.as_deref(), Some("see "));
7785        assert_eq!(content[1].node_type, "mediaInline");
7786        let attrs = content[1].attrs.as_ref().unwrap();
7787        assert_eq!(attrs["type"], "image");
7788        assert_eq!(attrs["id"], "abcdef01-2345-6789-abcd-abcdef012345");
7789        assert_eq!(attrs["collection"], "contentId-111111");
7790        assert_eq!(attrs["width"], 200);
7791        assert_eq!(attrs["height"], 100);
7792        assert_eq!(content[2].text.as_deref(), Some(" for details"));
7793    }
7794
7795    #[test]
7796    fn media_inline_external_url_round_trip() {
7797        let doc = AdfDocument {
7798            version: 1,
7799            doc_type: "doc".to_string(),
7800            content: vec![AdfNode::paragraph(vec![AdfNode::media_inline(
7801                serde_json::json!({
7802                    "type": "external",
7803                    "url": "https://example.com/image.png",
7804                    "alt": "example",
7805                    "width": 400,
7806                    "height": 300
7807                }),
7808            )])],
7809        };
7810        let md = adf_to_markdown(&doc).unwrap();
7811        let rt = markdown_to_adf(&md).unwrap();
7812
7813        let content = rt.content[0].content.as_ref().unwrap();
7814        assert_eq!(content[0].node_type, "mediaInline");
7815        let attrs = content[0].attrs.as_ref().unwrap();
7816        assert_eq!(attrs["type"], "external");
7817        assert_eq!(attrs["url"], "https://example.com/image.png");
7818        assert_eq!(attrs["alt"], "example");
7819        assert_eq!(attrs["width"], 400);
7820        assert_eq!(attrs["height"], 300);
7821    }
7822
7823    #[test]
7824    fn media_inline_minimal_attrs() {
7825        let doc = AdfDocument {
7826            version: 1,
7827            doc_type: "doc".to_string(),
7828            content: vec![AdfNode::paragraph(vec![AdfNode::media_inline(
7829                serde_json::json!({"type": "file", "id": "abc-123"}),
7830            )])],
7831        };
7832        let md = adf_to_markdown(&doc).unwrap();
7833        let rt = markdown_to_adf(&md).unwrap();
7834
7835        let content = rt.content[0].content.as_ref().unwrap();
7836        assert_eq!(content[0].node_type, "mediaInline");
7837        let attrs = content[0].attrs.as_ref().unwrap();
7838        assert_eq!(attrs["type"], "file");
7839        assert_eq!(attrs["id"], "abc-123");
7840    }
7841
7842    #[test]
7843    fn media_inline_from_issue_476_reproducer() {
7844        // Exact reproducer from issue #476
7845        let adf_json: serde_json::Value = serde_json::json!({
7846            "type": "doc",
7847            "version": 1,
7848            "content": [
7849                {
7850                    "type": "paragraph",
7851                    "content": [
7852                        {"type": "text", "text": "see "},
7853                        {
7854                            "type": "mediaInline",
7855                            "attrs": {
7856                                "collection": "contentId-111111",
7857                                "height": 100,
7858                                "id": "abcdef01-2345-6789-abcd-abcdef012345",
7859                                "localId": "aabbccdd-1234-5678-abcd-aabbccdd1234",
7860                                "type": "image",
7861                                "width": 200
7862                            }
7863                        },
7864                        {"type": "text", "text": " for details"}
7865                    ]
7866                }
7867            ]
7868        });
7869        let doc: AdfDocument = serde_json::from_value(adf_json).unwrap();
7870        let md = adf_to_markdown(&doc).unwrap();
7871        assert!(
7872            !md.contains("<!-- unsupported inline"),
7873            "mediaInline should not be unsupported; got: {md}"
7874        );
7875
7876        let rt = markdown_to_adf(&md).unwrap();
7877        let content = rt.content[0].content.as_ref().unwrap();
7878        assert_eq!(content[1].node_type, "mediaInline");
7879        let attrs = content[1].attrs.as_ref().unwrap();
7880        assert_eq!(attrs["type"], "image");
7881        assert_eq!(attrs["id"], "abcdef01-2345-6789-abcd-abcdef012345");
7882        assert_eq!(attrs["collection"], "contentId-111111");
7883        assert_eq!(attrs["width"], 200);
7884        assert_eq!(attrs["height"], 100);
7885        assert_eq!(attrs["localId"], "aabbccdd-1234-5678-abcd-aabbccdd1234");
7886    }
7887
7888    #[test]
7889    fn emoji_shortcode() {
7890        let doc = markdown_to_adf("Hello :wave: world").unwrap();
7891        let content = doc.content[0].content.as_ref().unwrap();
7892        assert_eq!(content[0].text.as_deref(), Some("Hello "));
7893        assert_eq!(content[1].node_type, "emoji");
7894        assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":wave:");
7895        assert_eq!(content[2].text.as_deref(), Some(" world"));
7896    }
7897
7898    #[test]
7899    fn adf_emoji_to_markdown() {
7900        let doc = AdfDocument {
7901            version: 1,
7902            doc_type: "doc".to_string(),
7903            content: vec![AdfNode::paragraph(vec![AdfNode::emoji("thumbsup")])],
7904        };
7905        let md = adf_to_markdown(&doc).unwrap();
7906        assert!(md.contains(":thumbsup:"));
7907    }
7908
7909    #[test]
7910    fn adf_emoji_with_colon_prefix_to_markdown() {
7911        // JIRA stores shortName as ":thumbsup:" with colons
7912        let doc = AdfDocument {
7913            version: 1,
7914            doc_type: "doc".to_string(),
7915            content: vec![AdfNode::paragraph(vec![AdfNode {
7916                node_type: "emoji".to_string(),
7917                attrs: Some(serde_json::json!({"shortName": ":thumbsup:"})),
7918                content: None,
7919                text: None,
7920                marks: None,
7921                local_id: None,
7922                parameters: None,
7923            }])],
7924        };
7925        let md = adf_to_markdown(&doc).unwrap();
7926        assert!(md.contains(":thumbsup:"));
7927        // Should not produce ::thumbsup:: (double colons)
7928        assert!(!md.contains("::thumbsup::"));
7929    }
7930
7931    #[test]
7932    fn round_trip_emoji() {
7933        let md = "Hello :wave: world\n";
7934        let doc = markdown_to_adf(md).unwrap();
7935        let result = adf_to_markdown(&doc).unwrap();
7936        assert!(result.contains(":wave:"));
7937    }
7938
7939    #[test]
7940    fn emoji_with_id_and_text_round_trips() {
7941        let doc = AdfDocument {
7942            version: 1,
7943            doc_type: "doc".to_string(),
7944            content: vec![AdfNode::paragraph(vec![AdfNode {
7945                node_type: "emoji".to_string(),
7946                attrs: Some(
7947                    serde_json::json!({"shortName": ":check_mark:", "id": "2705", "text": "✅"}),
7948                ),
7949                content: None,
7950                text: None,
7951                marks: None,
7952                local_id: None,
7953                parameters: None,
7954            }])],
7955        };
7956        let md = adf_to_markdown(&doc).unwrap();
7957        assert!(md.contains(":check_mark:"), "shortcode present: {md}");
7958        assert!(md.contains("id="), "id attr present: {md}");
7959        assert!(md.contains("text="), "text attr present: {md}");
7960
7961        // Round-trip back to ADF
7962        let round_tripped = markdown_to_adf(&md).unwrap();
7963        let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
7964        let attrs = emoji.attrs.as_ref().unwrap();
7965        assert_eq!(attrs["shortName"], ":check_mark:");
7966        assert_eq!(attrs["id"], "2705");
7967        assert_eq!(attrs["text"], "✅");
7968    }
7969
7970    #[test]
7971    fn emoji_without_extra_attrs_still_works() {
7972        let md = "Hello :wave: world\n";
7973        let doc = markdown_to_adf(md).unwrap();
7974        let emoji = &doc.content[0].content.as_ref().unwrap()[1];
7975        assert_eq!(emoji.attrs.as_ref().unwrap()["shortName"], ":wave:");
7976        // No id or text attrs when not provided
7977        assert!(emoji.attrs.as_ref().unwrap().get("id").is_none());
7978    }
7979
7980    #[test]
7981    fn emoji_shortname_preserves_colons_round_trip() {
7982        // Issue #362: emoji shortName colons stripped during round-trip
7983        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
7984          {"type":"emoji","attrs":{"shortName":":cross_mark:","id":"atlassian-cross_mark","text":"❌"}}
7985        ]}]}"#;
7986        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
7987
7988        // ADF → markdown → ADF round-trip
7989        let md = adf_to_markdown(&doc).unwrap();
7990        let round_tripped = markdown_to_adf(&md).unwrap();
7991        let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
7992        let attrs = emoji.attrs.as_ref().unwrap();
7993        assert_eq!(
7994            attrs["shortName"], ":cross_mark:",
7995            "shortName should preserve colons, got: {}",
7996            attrs["shortName"]
7997        );
7998        assert_eq!(attrs["id"], "atlassian-cross_mark");
7999        assert_eq!(attrs["text"], "❌");
8000    }
8001
8002    #[test]
8003    fn emoji_shortname_without_colons_preserved() {
8004        // Issue #379: shortName without colons should not gain colons
8005        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8006          {"type":"emoji","attrs":{"shortName":"white_check_mark","id":"2705","text":"✅"}}
8007        ]}]}"#;
8008        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8009        let md = adf_to_markdown(&doc).unwrap();
8010        let round_tripped = markdown_to_adf(&md).unwrap();
8011        let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
8012        let attrs = emoji.attrs.as_ref().unwrap();
8013        assert_eq!(
8014            attrs["shortName"], "white_check_mark",
8015            "shortName without colons should stay without colons, got: {}",
8016            attrs["shortName"]
8017        );
8018    }
8019
8020    #[test]
8021    fn colon_in_text_not_emoji() {
8022        // A lone colon should not trigger emoji parsing
8023        let doc = markdown_to_adf("Time is 10:30 today").unwrap();
8024        let content = doc.content[0].content.as_ref().unwrap();
8025        assert_eq!(content.len(), 1);
8026        assert_eq!(content[0].node_type, "text");
8027    }
8028
8029    #[test]
8030    fn text_with_shortcode_pattern_round_trips_as_text() {
8031        // Issue #450: `:fire:` in a text node must not become an emoji node
8032        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Alert :fire: triggered on pod:pod42"}]}]}"#;
8033        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8034
8035        let md = adf_to_markdown(&doc).unwrap();
8036        let round_tripped = markdown_to_adf(&md).unwrap();
8037        let content = round_tripped.content[0].content.as_ref().unwrap();
8038
8039        assert_eq!(
8040            content.len(),
8041            1,
8042            "should be a single text node, got: {content:?}"
8043        );
8044        assert_eq!(content[0].node_type, "text");
8045        assert_eq!(
8046            content[0].text.as_deref().unwrap(),
8047            "Alert :fire: triggered on pod:pod42"
8048        );
8049    }
8050
8051    #[test]
8052    fn double_colon_pattern_round_trips_as_text() {
8053        // Issue #450: `::Active::` should not be parsed as emoji `:Active:`
8054        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Status::Active::Running"}]}]}"#;
8055        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8056
8057        let md = adf_to_markdown(&doc).unwrap();
8058        let round_tripped = markdown_to_adf(&md).unwrap();
8059        let content = round_tripped.content[0].content.as_ref().unwrap();
8060
8061        assert_eq!(
8062            content.len(),
8063            1,
8064            "should be a single text node, got: {content:?}"
8065        );
8066        assert_eq!(content[0].node_type, "text");
8067        assert_eq!(
8068            content[0].text.as_deref().unwrap(),
8069            "Status::Active::Running"
8070        );
8071    }
8072
8073    #[test]
8074    fn real_emoji_node_still_round_trips() {
8075        // Ensure actual emoji ADF nodes still work after the escaping fix
8076        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8077          {"type":"text","text":"Hello "},
8078          {"type":"emoji","attrs":{"shortName":":fire:","id":"1f525","text":"🔥"}},
8079          {"type":"text","text":" world"}
8080        ]}]}"#;
8081        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8082
8083        let md = adf_to_markdown(&doc).unwrap();
8084        let round_tripped = markdown_to_adf(&md).unwrap();
8085        let content = round_tripped.content[0].content.as_ref().unwrap();
8086
8087        // Should have: text("Hello ") + emoji(:fire:) + text(" world")
8088        assert_eq!(content.len(), 3, "should have 3 nodes: {content:?}");
8089        assert_eq!(content[0].text.as_deref(), Some("Hello "));
8090        assert_eq!(content[1].node_type, "emoji");
8091        assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":fire:");
8092        assert_eq!(content[2].text.as_deref(), Some(" world"));
8093    }
8094
8095    #[test]
8096    fn combined_emoji_shortname_round_trips_as_single_node() {
8097        // Issue #576: an emoji node whose shortName is a combination of two
8098        // shortcodes (e.g. ":slightly_smiling_face::bow:") must survive the
8099        // ADF → markdown → ADF round-trip as a single emoji node rather than
8100        // being split into two.
8101        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8102          {"type":"text","text":"Thanks for the help "},
8103          {"type":"emoji","attrs":{"shortName":":slightly_smiling_face::bow:","id":"","text":""}}
8104        ]}]}"#;
8105        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8106
8107        let md = adf_to_markdown(&doc).unwrap();
8108        let round_tripped = markdown_to_adf(&md).unwrap();
8109        let content = round_tripped.content[0].content.as_ref().unwrap();
8110
8111        assert_eq!(
8112            content.len(),
8113            2,
8114            "should have text + single combined emoji: {content:?}"
8115        );
8116        assert_eq!(content[0].text.as_deref(), Some("Thanks for the help "));
8117        assert_eq!(content[1].node_type, "emoji");
8118        let attrs = content[1].attrs.as_ref().unwrap();
8119        assert_eq!(attrs["shortName"], ":slightly_smiling_face::bow:");
8120        assert_eq!(attrs["id"], "");
8121        assert_eq!(attrs["text"], "");
8122    }
8123
8124    #[test]
8125    fn triple_combined_emoji_shortname_round_trips_as_single_node() {
8126        // Three-part combined shortName must also survive round-trip.
8127        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8128          {"type":"emoji","attrs":{"shortName":":a::b::c:","id":"x","text":""}}
8129        ]}]}"#;
8130        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8131
8132        let md = adf_to_markdown(&doc).unwrap();
8133        let round_tripped = markdown_to_adf(&md).unwrap();
8134        let content = round_tripped.content[0].content.as_ref().unwrap();
8135
8136        assert_eq!(content.len(), 1, "should be single emoji: {content:?}");
8137        assert_eq!(content[0].node_type, "emoji");
8138        let attrs = content[0].attrs.as_ref().unwrap();
8139        assert_eq!(attrs["shortName"], ":a::b::c:");
8140        assert_eq!(attrs["id"], "x");
8141    }
8142
8143    #[test]
8144    fn consecutive_emojis_remain_separate_nodes() {
8145        // Two independent emoji nodes (each with their own directive) must
8146        // remain two separate nodes — the combined-chain logic must not
8147        // swallow the second emoji.
8148        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8149          {"type":"emoji","attrs":{"shortName":":fire:","id":"1f525","text":"🔥"}},
8150          {"type":"emoji","attrs":{"shortName":":water:","id":"1f4a7","text":"💧"}}
8151        ]}]}"#;
8152        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8153
8154        let md = adf_to_markdown(&doc).unwrap();
8155        let round_tripped = markdown_to_adf(&md).unwrap();
8156        let content = round_tripped.content[0].content.as_ref().unwrap();
8157
8158        assert_eq!(content.len(), 2, "should be two emoji nodes: {content:?}");
8159        assert_eq!(content[0].node_type, "emoji");
8160        assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":fire:");
8161        assert_eq!(content[1].node_type, "emoji");
8162        assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":water:");
8163    }
8164
8165    #[test]
8166    fn adjacent_shortcodes_without_directive_parse_as_two_emojis() {
8167        // Raw markdown with two adjacent shortcodes and no directive should
8168        // still parse as two separate emoji nodes.
8169        let md = ":fire::water:";
8170        let doc = markdown_to_adf(md).unwrap();
8171        let content = doc.content[0].content.as_ref().unwrap();
8172
8173        assert_eq!(content.len(), 2, "should be two emojis: {content:?}");
8174        assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":fire:");
8175        assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":water:");
8176    }
8177
8178    #[test]
8179    fn combined_emoji_shortname_preserves_local_id() {
8180        // The directive's localId should be preserved when the chain is
8181        // collapsed into a single emoji node.
8182        let md = r#":a::b:{shortName=":a::b:" id="x" text="y" localId="abc"}"#;
8183        let doc = markdown_to_adf(md).unwrap();
8184        let content = doc.content[0].content.as_ref().unwrap();
8185
8186        assert_eq!(content.len(), 1, "should be single emoji: {content:?}");
8187        let attrs = content[0].attrs.as_ref().unwrap();
8188        assert_eq!(attrs["shortName"], ":a::b:");
8189        assert_eq!(attrs["id"], "x");
8190        assert_eq!(attrs["text"], "y");
8191        assert_eq!(attrs["localId"], "abc");
8192    }
8193
8194    #[test]
8195    fn text_shortcode_with_marks_round_trips() {
8196        // Bold text containing an emoji-like shortcode should round-trip as bold text
8197        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8198          {"type":"text","text":"Alert :fire: triggered","marks":[{"type":"strong"}]}
8199        ]}]}"#;
8200        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8201
8202        let md = adf_to_markdown(&doc).unwrap();
8203        let round_tripped = markdown_to_adf(&md).unwrap();
8204        let content = round_tripped.content[0].content.as_ref().unwrap();
8205
8206        assert_eq!(
8207            content.len(),
8208            1,
8209            "should be single bold text node: {content:?}"
8210        );
8211        assert_eq!(content[0].node_type, "text");
8212        assert_eq!(
8213            content[0].text.as_deref().unwrap(),
8214            "Alert :fire: triggered"
8215        );
8216        assert!(content[0]
8217            .marks
8218            .as_ref()
8219            .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong")));
8220    }
8221
8222    #[test]
8223    fn mixed_emoji_node_and_text_shortcode_round_trips() {
8224        // Real emoji node adjacent to text containing a shortcode-like pattern
8225        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8226          {"type":"emoji","attrs":{"shortName":":wave:","id":"1f44b","text":"👋"}},
8227          {"type":"text","text":" says :hello: to you"}
8228        ]}]}"#;
8229        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8230
8231        let md = adf_to_markdown(&doc).unwrap();
8232        let round_tripped = markdown_to_adf(&md).unwrap();
8233        let content = round_tripped.content[0].content.as_ref().unwrap();
8234
8235        // Should be: emoji(:wave:) + text(" says :hello: to you")
8236        assert_eq!(content.len(), 2, "should have 2 nodes: {content:?}");
8237        assert_eq!(content[0].node_type, "emoji");
8238        assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":wave:");
8239        assert_eq!(content[1].node_type, "text");
8240        assert_eq!(content[1].text.as_deref().unwrap(), " says :hello: to you");
8241    }
8242
8243    #[test]
8244    fn code_block_with_shortcode_pattern_round_trips() {
8245        // Issue #552: Code block content containing `::Name::` patterns must not
8246        // be re-parsed as emoji shortcodes.
8247        let adf_json = r#"{"version":1,"type":"doc","content":[
8248          {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8249            {"type":"text","text":"module Foo::Bar::Baz\n  def hello\n    puts 'world'\n  end\nend"}
8250          ]}
8251        ]}"#;
8252        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8253
8254        let md = adf_to_markdown(&doc).unwrap();
8255        let round_tripped = markdown_to_adf(&md).unwrap();
8256
8257        assert_eq!(
8258            round_tripped.content.len(),
8259            1,
8260            "should be a single codeBlock"
8261        );
8262        let cb = &round_tripped.content[0];
8263        assert_eq!(cb.node_type, "codeBlock");
8264        let content = cb.content.as_ref().expect("codeBlock content");
8265        assert_eq!(
8266            content.len(),
8267            1,
8268            "should be a single text node: {content:?}"
8269        );
8270        assert_eq!(content[0].node_type, "text");
8271        assert_eq!(
8272            content[0].text.as_deref().unwrap(),
8273            "module Foo::Bar::Baz\n  def hello\n    puts 'world'\n  end\nend"
8274        );
8275        assert!(
8276            content.iter().all(|n| n.node_type != "emoji"),
8277            "no emoji nodes should be present, got: {content:?}"
8278        );
8279    }
8280
8281    #[test]
8282    fn code_block_with_exact_zendesk_shortcode_pattern_round_trips() {
8283        // Issue #552: Use the exact pattern from the bug report.
8284        let adf_json = r#"{"version":1,"type":"doc","content":[
8285          {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8286            {"type":"text","text":"class ZBC::Zendesk::PlanType::Professional < Base"}
8287          ]}
8288        ]}"#;
8289        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8290
8291        let md = adf_to_markdown(&doc).unwrap();
8292        let round_tripped = markdown_to_adf(&md).unwrap();
8293
8294        let cb = &round_tripped.content[0];
8295        assert_eq!(cb.node_type, "codeBlock");
8296        let content = cb.content.as_ref().expect("codeBlock content");
8297        assert_eq!(content.len(), 1, "should be a single text node");
8298        assert_eq!(
8299            content[0].text.as_deref().unwrap(),
8300            "class ZBC::Zendesk::PlanType::Professional < Base"
8301        );
8302    }
8303
8304    #[test]
8305    fn code_block_with_literal_shortcode_round_trips() {
8306        // Issue #552: Content that is exactly a shortcode (`:fire:`) inside a
8307        // code block should survive the round-trip as literal text, not emoji.
8308        let adf_json = r#"{"version":1,"type":"doc","content":[
8309          {"type":"codeBlock","attrs":{"language":"text"},"content":[
8310            {"type":"text","text":":fire: :wave: :thumbsup:"}
8311          ]}
8312        ]}"#;
8313        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8314
8315        let md = adf_to_markdown(&doc).unwrap();
8316        let round_tripped = markdown_to_adf(&md).unwrap();
8317
8318        let cb = &round_tripped.content[0];
8319        assert_eq!(cb.node_type, "codeBlock");
8320        let content = cb.content.as_ref().expect("codeBlock content");
8321        assert_eq!(
8322            content.len(),
8323            1,
8324            "should be a single text node: {content:?}"
8325        );
8326        assert_eq!(content[0].node_type, "text");
8327        assert_eq!(
8328            content[0].text.as_deref().unwrap(),
8329            ":fire: :wave: :thumbsup:"
8330        );
8331    }
8332
8333    #[test]
8334    fn inline_code_with_shortcode_pattern_round_trips() {
8335        // Issue #552: Inline code containing `::Name::` patterns must not be
8336        // re-parsed as emoji shortcodes.
8337        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8338          {"type":"text","text":"See "},
8339          {"type":"text","text":"Foo::Bar::Baz","marks":[{"type":"code"}]},
8340          {"type":"text","text":" for details"}
8341        ]}]}"#;
8342        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8343
8344        let md = adf_to_markdown(&doc).unwrap();
8345        let round_tripped = markdown_to_adf(&md).unwrap();
8346        let content = round_tripped.content[0].content.as_ref().unwrap();
8347
8348        assert_eq!(content.len(), 3, "should have 3 text nodes: {content:?}");
8349        assert_eq!(content[0].text.as_deref(), Some("See "));
8350        assert_eq!(content[1].text.as_deref(), Some("Foo::Bar::Baz"));
8351        assert!(content[1]
8352            .marks
8353            .as_ref()
8354            .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
8355        assert_eq!(content[2].text.as_deref(), Some(" for details"));
8356        assert!(
8357            content.iter().all(|n| n.node_type != "emoji"),
8358            "no emoji nodes should be present"
8359        );
8360    }
8361
8362    #[test]
8363    fn inline_code_with_literal_shortcode_round_trips() {
8364        // Issue #552: Inline code whose content is exactly a shortcode must be
8365        // preserved as code, not converted to an emoji.
8366        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8367          {"type":"text","text":":fire:","marks":[{"type":"code"}]}
8368        ]}]}"#;
8369        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8370
8371        let md = adf_to_markdown(&doc).unwrap();
8372        let round_tripped = markdown_to_adf(&md).unwrap();
8373        let content = round_tripped.content[0].content.as_ref().unwrap();
8374
8375        assert_eq!(
8376            content.len(),
8377            1,
8378            "should be a single code node: {content:?}"
8379        );
8380        assert_eq!(content[0].node_type, "text");
8381        assert_eq!(content[0].text.as_deref(), Some(":fire:"));
8382        assert!(content[0]
8383            .marks
8384            .as_ref()
8385            .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
8386    }
8387
8388    #[test]
8389    fn code_block_in_list_with_shortcode_pattern_round_trips() {
8390        // Issue #552: A code block containing shortcode-like patterns nested in
8391        // a list should still survive round-trip without emoji corruption.
8392        let adf_json = r#"{"version":1,"type":"doc","content":[
8393          {"type":"bulletList","content":[
8394            {"type":"listItem","content":[
8395              {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8396                {"type":"text","text":"Foo::Bar::Baz"}
8397              ]}
8398            ]}
8399          ]}
8400        ]}"#;
8401        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8402
8403        let md = adf_to_markdown(&doc).unwrap();
8404        let round_tripped = markdown_to_adf(&md).unwrap();
8405
8406        let list = &round_tripped.content[0];
8407        assert_eq!(list.node_type, "bulletList");
8408        let item = &list.content.as_ref().unwrap()[0];
8409        assert_eq!(item.node_type, "listItem");
8410        let cb = &item.content.as_ref().unwrap()[0];
8411        assert_eq!(cb.node_type, "codeBlock");
8412        let cb_content = cb.content.as_ref().unwrap();
8413        assert_eq!(cb_content[0].text.as_deref(), Some("Foo::Bar::Baz"));
8414        assert_eq!(cb_content[0].node_type, "text");
8415    }
8416
8417    #[test]
8418    fn code_block_with_unicode_shortcode_pattern_round_trips() {
8419        // Issue #552: Code block content with non-ASCII colon-delimited words
8420        // (e.g. CJK or accented characters) must round-trip without splitting
8421        // or emoji corruption.
8422        let adf_json = r#"{"version":1,"type":"doc","content":[
8423          {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8424            {"type":"text","text":"class ZBC::配置::Production < Base"}
8425          ]}
8426        ]}"#;
8427        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8428
8429        let md = adf_to_markdown(&doc).unwrap();
8430        let round_tripped = markdown_to_adf(&md).unwrap();
8431
8432        let cb = &round_tripped.content[0];
8433        assert_eq!(cb.node_type, "codeBlock");
8434        let content = cb.content.as_ref().expect("codeBlock content");
8435        assert_eq!(content.len(), 1);
8436        assert_eq!(
8437            content[0].text.as_deref().unwrap(),
8438            "class ZBC::配置::Production < Base"
8439        );
8440    }
8441
8442    #[test]
8443    fn list_item_hardbreak_then_code_block_round_trips() {
8444        // Issue #552: A list item whose first paragraph ends with a hardBreak
8445        // followed by a codeBlock must round-trip correctly.  Previously, the
8446        // hardBreak's `\` continuation swallowed the 2-space-indented code
8447        // fence line, turning the whole block into a paragraph where `::Bar::`
8448        // was re-parsed as an emoji.
8449        let adf_json = r#"{"version":1,"type":"doc","content":[
8450          {"type":"bulletList","content":[
8451            {"type":"listItem","content":[
8452              {"type":"paragraph","content":[
8453                {"type":"text","text":"Consider removing this check."},
8454                {"type":"hardBreak"}
8455              ]},
8456              {"type":"codeBlock","content":[
8457                {"type":"text","text":"x = Foo::Bar::Baz.new"}
8458              ]}
8459            ]}
8460          ]}
8461        ]}"#;
8462        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8463
8464        let md = adf_to_markdown(&doc).unwrap();
8465        let round_tripped = markdown_to_adf(&md).unwrap();
8466
8467        let list = &round_tripped.content[0];
8468        assert_eq!(list.node_type, "bulletList");
8469        let item = &list.content.as_ref().unwrap()[0];
8470        assert_eq!(item.node_type, "listItem");
8471        let item_content = item.content.as_ref().unwrap();
8472        assert_eq!(
8473            item_content.len(),
8474            2,
8475            "listItem should have paragraph + codeBlock, got: {item_content:?}"
8476        );
8477        assert_eq!(item_content[0].node_type, "paragraph");
8478        assert_eq!(item_content[1].node_type, "codeBlock");
8479
8480        // The code block text must survive verbatim — no emoji splitting.
8481        let cb_content = item_content[1].content.as_ref().unwrap();
8482        assert_eq!(cb_content[0].node_type, "text");
8483        assert_eq!(
8484            cb_content[0].text.as_deref().unwrap(),
8485            "x = Foo::Bar::Baz.new"
8486        );
8487
8488        // And there should be no emoji node anywhere in the tree.
8489        assert!(
8490            item_content
8491                .iter()
8492                .flat_map(|n| n.content.iter().flat_map(|c| c.iter()))
8493                .all(|n| n.node_type != "emoji"),
8494            "no emoji nodes should be present, got: {item_content:?}"
8495        );
8496    }
8497
8498    #[test]
8499    fn list_item_hardbreak_then_nested_list_still_works() {
8500        // Ensure the hardBreak continuation fix doesn't break nested list
8501        // handling — an indented `- item` line after a hardBreak is still a
8502        // nested list, not a code fence.
8503        let md = "- first\\\n  continuation text\\\n  - nested item\n";
8504        let doc = markdown_to_adf(md).unwrap();
8505        let list = &doc.content[0];
8506        assert_eq!(list.node_type, "bulletList");
8507        let item = &list.content.as_ref().unwrap()[0];
8508        // First paragraph should contain the continuation text joined via hardBreaks
8509        let item_content = item.content.as_ref().unwrap();
8510        let para = &item_content[0];
8511        assert_eq!(para.node_type, "paragraph");
8512        let para_nodes = para.content.as_ref().unwrap();
8513        assert!(
8514            para_nodes
8515                .iter()
8516                .any(|n| n.text.as_deref() == Some("continuation text")),
8517            "continuation text should survive: {para_nodes:?}"
8518        );
8519    }
8520
8521    #[test]
8522    fn list_item_hardbreak_then_image_still_works() {
8523        // Regression check for issue #490: the image-skip behaviour in
8524        // collect_hardbreak_continuations must still hold after the code-fence
8525        // fix.  The `![](url)` line must remain a block-level mediaSingle, not
8526        // be swallowed into the paragraph.
8527        let md = "- first\\\n  ![](https://example.com/x.png){type=file id=x}\n";
8528        let doc = markdown_to_adf(md).unwrap();
8529        let list = &doc.content[0];
8530        let item = &list.content.as_ref().unwrap()[0];
8531        let item_content = item.content.as_ref().unwrap();
8532        // The image should be a sibling block, not part of the first paragraph.
8533        assert!(
8534            item_content.iter().any(|n| n.node_type == "mediaSingle"),
8535            "mediaSingle should still be a block-level sibling, got: {item_content:?}"
8536        );
8537    }
8538
8539    #[test]
8540    fn list_item_hardbreak_then_container_directive_round_trips() {
8541        // Issue #552: Same hardBreak-swallows-block-siblings bug class — a
8542        // container directive (`:::panel`) after a hardBreak must also not be
8543        // consumed as a continuation line.
8544        let adf_json = r#"{"version":1,"type":"doc","content":[
8545          {"type":"bulletList","content":[
8546            {"type":"listItem","content":[
8547              {"type":"paragraph","content":[
8548                {"type":"text","text":"intro"},
8549                {"type":"hardBreak"}
8550              ]},
8551              {"type":"panel","attrs":{"panelType":"info"},"content":[
8552                {"type":"paragraph","content":[
8553                  {"type":"text","text":"panel body"}
8554                ]}
8555              ]}
8556            ]}
8557          ]}
8558        ]}"#;
8559        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8560        let md = adf_to_markdown(&doc).unwrap();
8561        let round_tripped = markdown_to_adf(&md).unwrap();
8562
8563        let item = &round_tripped.content[0].content.as_ref().unwrap()[0];
8564        let item_content = item.content.as_ref().unwrap();
8565        assert!(
8566            item_content.iter().any(|n| n.node_type == "panel"),
8567            "panel should survive as block-level sibling, got: {item_content:?}"
8568        );
8569    }
8570
8571    #[test]
8572    fn inline_code_with_unicode_shortcode_pattern_round_trips() {
8573        // Issue #552: Inline code containing non-ASCII colon-delimited words
8574        // must round-trip without emoji corruption.
8575        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8576          {"type":"text","text":"See "},
8577          {"type":"text","text":"ZBC::配置::Production","marks":[{"type":"code"}]},
8578          {"type":"text","text":" for prod"}
8579        ]}]}"#;
8580        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8581
8582        let md = adf_to_markdown(&doc).unwrap();
8583        let round_tripped = markdown_to_adf(&md).unwrap();
8584        let content = round_tripped.content[0].content.as_ref().unwrap();
8585
8586        assert_eq!(content.len(), 3, "should have 3 nodes: {content:?}");
8587        assert_eq!(content[1].text.as_deref(), Some("ZBC::配置::Production"));
8588        assert!(content[1]
8589            .marks
8590            .as_ref()
8591            .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
8592    }
8593
8594    #[test]
8595    fn code_block_followed_by_shortcode_text_round_trips() {
8596        // Issue #552: A code block with colon-delimited content followed by a
8597        // paragraph containing emoji-like text should not confuse parsing.
8598        let adf_json = r#"{"version":1,"type":"doc","content":[
8599          {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8600            {"type":"text","text":"Foo::Bar::Baz"}
8601          ]},
8602          {"type":"paragraph","content":[
8603            {"type":"text","text":"Status::Active::Running"}
8604          ]}
8605        ]}"#;
8606        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8607
8608        let md = adf_to_markdown(&doc).unwrap();
8609        let round_tripped = markdown_to_adf(&md).unwrap();
8610
8611        assert_eq!(round_tripped.content.len(), 2);
8612        let cb = &round_tripped.content[0];
8613        assert_eq!(cb.node_type, "codeBlock");
8614        let cb_content = cb.content.as_ref().unwrap();
8615        assert_eq!(cb_content[0].text.as_deref(), Some("Foo::Bar::Baz"));
8616
8617        let para = &round_tripped.content[1];
8618        assert_eq!(para.node_type, "paragraph");
8619        let para_content = para.content.as_ref().unwrap();
8620        assert_eq!(para_content.len(), 1);
8621        assert_eq!(para_content[0].node_type, "text");
8622        assert_eq!(
8623            para_content[0].text.as_deref(),
8624            Some("Status::Active::Running")
8625        );
8626    }
8627
8628    #[test]
8629    fn adf_inline_card_to_markdown() {
8630        let doc = AdfDocument {
8631            version: 1,
8632            doc_type: "doc".to_string(),
8633            content: vec![AdfNode::paragraph(vec![AdfNode {
8634                node_type: "inlineCard".to_string(),
8635                attrs: Some(
8636                    serde_json::json!({"url": "https://org.atlassian.net/browse/ACCS-4382"}),
8637                ),
8638                content: None,
8639                text: None,
8640                marks: None,
8641                local_id: None,
8642                parameters: None,
8643            }])],
8644        };
8645        let md = adf_to_markdown(&doc).unwrap();
8646        assert!(md.contains(":card[https://org.atlassian.net/browse/ACCS-4382]"));
8647        assert!(!md.contains("<!-- unsupported inline"));
8648    }
8649
8650    #[test]
8651    fn inline_card_directive_round_trips() {
8652        // inlineCard → :card[url] → inlineCard
8653        let original = AdfDocument {
8654            version: 1,
8655            doc_type: "doc".to_string(),
8656            content: vec![AdfNode::paragraph(vec![AdfNode::inline_card(
8657                "https://org.atlassian.net/browse/ACCS-4382",
8658            )])],
8659        };
8660        let md = adf_to_markdown(&original).unwrap();
8661        assert!(md.contains(":card[https://org.atlassian.net/browse/ACCS-4382]"));
8662        let restored = markdown_to_adf(&md).unwrap();
8663        let node = &restored.content[0].content.as_ref().unwrap()[0];
8664        assert_eq!(node.node_type, "inlineCard");
8665        assert_eq!(
8666            node.attrs.as_ref().unwrap()["url"],
8667            "https://org.atlassian.net/browse/ACCS-4382"
8668        );
8669    }
8670
8671    #[test]
8672    fn inline_card_directive_parsed_from_jfm() {
8673        // :card[url] in JFM → inlineCard in ADF
8674        let doc = markdown_to_adf("See :card[https://example.com/issue/123] for details.").unwrap();
8675        let nodes = doc.content[0].content.as_ref().unwrap();
8676        assert_eq!(nodes[0].node_type, "text");
8677        assert_eq!(nodes[0].text.as_deref(), Some("See "));
8678        assert_eq!(nodes[1].node_type, "inlineCard");
8679        assert_eq!(
8680            nodes[1].attrs.as_ref().unwrap()["url"],
8681            "https://example.com/issue/123"
8682        );
8683        assert_eq!(nodes[2].node_type, "text");
8684        assert_eq!(nodes[2].text.as_deref(), Some(" for details."));
8685    }
8686
8687    #[test]
8688    fn self_link_becomes_link_mark_not_inline_card() {
8689        // Issue #378: [url](url) should produce a link mark, not inlineCard.
8690        // inlineCard is only for :card[url] directives and bare URLs.
8691        let doc = markdown_to_adf("[https://example.com](https://example.com)").unwrap();
8692        let node = &doc.content[0].content.as_ref().unwrap()[0];
8693        assert_eq!(node.node_type, "text");
8694        assert_eq!(node.text.as_deref(), Some("https://example.com"));
8695        let mark = &node.marks.as_ref().unwrap()[0];
8696        assert_eq!(mark.mark_type, "link");
8697        assert_eq!(mark.attrs.as_ref().unwrap()["href"], "https://example.com");
8698    }
8699
8700    #[test]
8701    fn url_link_text_with_trailing_slash_mismatch_becomes_link_mark() {
8702        // Issue #523: [url](url/) where text and href differ only by trailing
8703        // slash should produce a text node with link mark, not an inlineCard.
8704        let doc =
8705            markdown_to_adf("[https://octopz.example.com](https://octopz.example.com/)").unwrap();
8706        let node = &doc.content[0].content.as_ref().unwrap()[0];
8707        assert_eq!(node.node_type, "text");
8708        assert_eq!(node.text.as_deref(), Some("https://octopz.example.com"));
8709        let mark = &node.marks.as_ref().unwrap()[0];
8710        assert_eq!(mark.mark_type, "link");
8711        assert_eq!(
8712            mark.attrs.as_ref().unwrap()["href"],
8713            "https://octopz.example.com/"
8714        );
8715    }
8716
8717    #[test]
8718    fn named_link_does_not_become_inline_card() {
8719        // [#4668](url) — text differs from url, stays as a link mark
8720        let doc = markdown_to_adf("[#4668](https://github.com/org/repo/pull/4668)").unwrap();
8721        let node = &doc.content[0].content.as_ref().unwrap()[0];
8722        assert_eq!(node.node_type, "text");
8723        assert_eq!(node.text.as_deref(), Some("#4668"));
8724        let mark = &node.marks.as_ref().unwrap()[0];
8725        assert_eq!(mark.mark_type, "link");
8726    }
8727
8728    #[test]
8729    fn adf_inline_card_no_url_to_markdown() {
8730        let doc = AdfDocument {
8731            version: 1,
8732            doc_type: "doc".to_string(),
8733            content: vec![AdfNode::paragraph(vec![AdfNode {
8734                node_type: "inlineCard".to_string(),
8735                attrs: Some(serde_json::json!({})),
8736                content: None,
8737                text: None,
8738                marks: None,
8739                local_id: None,
8740                parameters: None,
8741            }])],
8742        };
8743        let md = adf_to_markdown(&doc).unwrap();
8744        // No url attr — renders nothing (not a comment)
8745        assert!(!md.contains("<!-- unsupported inline"));
8746    }
8747
8748    #[test]
8749    fn adf_code_block_no_language_to_markdown() {
8750        let doc = AdfDocument {
8751            version: 1,
8752            doc_type: "doc".to_string(),
8753            content: vec![AdfNode::code_block(None, "plain code")],
8754        };
8755        let md = adf_to_markdown(&doc).unwrap();
8756        assert!(md.contains("```\n"));
8757        assert!(md.contains("plain code"));
8758    }
8759
8760    #[test]
8761    fn adf_code_block_empty_language_to_markdown() {
8762        let doc = AdfDocument {
8763            version: 1,
8764            doc_type: "doc".to_string(),
8765            content: vec![AdfNode::code_block(Some(""), "plain code")],
8766        };
8767        let md = adf_to_markdown(&doc).unwrap();
8768        assert!(md.contains("```\"\"\n"));
8769        assert!(md.contains("plain code"));
8770    }
8771
8772    // ── Additional round-trip tests ────────────────────────────────────
8773
8774    #[test]
8775    fn round_trip_table() {
8776        let md = "| A | B |\n| --- | --- |\n| 1 | 2 |\n";
8777        let adf = markdown_to_adf(md).unwrap();
8778        let restored = adf_to_markdown(&adf).unwrap();
8779        assert!(restored.contains("| A | B |"));
8780        assert!(restored.contains("| 1 | 2 |"));
8781    }
8782
8783    #[test]
8784    fn round_trip_blockquote() {
8785        let md = "> This is quoted\n";
8786        let adf = markdown_to_adf(md).unwrap();
8787        let restored = adf_to_markdown(&adf).unwrap();
8788        assert!(restored.contains("> This is quoted"));
8789    }
8790
8791    #[test]
8792    fn round_trip_image() {
8793        let md = "![Logo](https://example.com/logo.png)\n";
8794        let adf = markdown_to_adf(md).unwrap();
8795        let restored = adf_to_markdown(&adf).unwrap();
8796        assert!(restored.contains("![Logo](https://example.com/logo.png)"));
8797    }
8798
8799    #[test]
8800    fn round_trip_ordered_list() {
8801        let md = "1. A\n2. B\n3. C\n";
8802        let adf = markdown_to_adf(md).unwrap();
8803        let restored = adf_to_markdown(&adf).unwrap();
8804        assert!(restored.contains("1. A"));
8805        assert!(restored.contains("2. B"));
8806        assert!(restored.contains("3. C"));
8807    }
8808
8809    #[test]
8810    fn round_trip_inline_marks() {
8811        let md = "Text with `code` and ~~strike~~ and [link](https://x.com).\n";
8812        let adf = markdown_to_adf(md).unwrap();
8813        let restored = adf_to_markdown(&adf).unwrap();
8814        assert!(restored.contains("`code`"));
8815        assert!(restored.contains("~~strike~~"));
8816        assert!(restored.contains("[link](https://x.com)"));
8817    }
8818
8819    // ── Container directive tests (Tier 2) ───────────────────────────
8820
8821    #[test]
8822    fn panel_info() {
8823        let md = ":::panel{type=info}\nThis is informational.\n:::";
8824        let doc = markdown_to_adf(md).unwrap();
8825        assert_eq!(doc.content[0].node_type, "panel");
8826        assert_eq!(doc.content[0].attrs.as_ref().unwrap()["panelType"], "info");
8827        let inner = doc.content[0].content.as_ref().unwrap();
8828        assert_eq!(inner[0].node_type, "paragraph");
8829    }
8830
8831    #[test]
8832    fn adf_panel_to_markdown() {
8833        let doc = AdfDocument {
8834            version: 1,
8835            doc_type: "doc".to_string(),
8836            content: vec![AdfNode::panel(
8837                "warning",
8838                vec![AdfNode::paragraph(vec![AdfNode::text("Be careful.")])],
8839            )],
8840        };
8841        let md = adf_to_markdown(&doc).unwrap();
8842        assert!(md.contains(":::panel{type=warning}"));
8843        assert!(md.contains("Be careful."));
8844        assert!(md.contains(":::"));
8845    }
8846
8847    #[test]
8848    fn round_trip_panel() {
8849        let md = ":::panel{type=info}\nThis is informational.\n:::\n";
8850        let doc = markdown_to_adf(md).unwrap();
8851        let result = adf_to_markdown(&doc).unwrap();
8852        assert!(result.contains(":::panel{type=info}"));
8853        assert!(result.contains("This is informational."));
8854    }
8855
8856    #[test]
8857    fn expand_with_title() {
8858        let md = ":::expand{title=\"Click me\"}\nHidden content.\n:::";
8859        let doc = markdown_to_adf(md).unwrap();
8860        assert_eq!(doc.content[0].node_type, "expand");
8861        assert_eq!(doc.content[0].attrs.as_ref().unwrap()["title"], "Click me");
8862    }
8863
8864    #[test]
8865    fn adf_expand_to_markdown() {
8866        let doc = AdfDocument {
8867            version: 1,
8868            doc_type: "doc".to_string(),
8869            content: vec![AdfNode::expand(
8870                Some("Details"),
8871                vec![AdfNode::paragraph(vec![AdfNode::text("Inner.")])],
8872            )],
8873        };
8874        let md = adf_to_markdown(&doc).unwrap();
8875        assert!(md.contains(":::expand{title=\"Details\"}"));
8876        assert!(md.contains("Inner."));
8877    }
8878
8879    #[test]
8880    fn round_trip_expand() {
8881        let md = ":::expand{title=\"Details\"}\nInner content.\n:::\n";
8882        let doc = markdown_to_adf(md).unwrap();
8883        let result = adf_to_markdown(&doc).unwrap();
8884        assert!(result.contains(":::expand{title=\"Details\"}"));
8885        assert!(result.contains("Inner content."));
8886    }
8887
8888    #[test]
8889    fn layout_two_columns() {
8890        let md =
8891            "::::layout\n:::column{width=50}\nLeft.\n:::\n:::column{width=50}\nRight.\n:::\n::::";
8892        let doc = markdown_to_adf(md).unwrap();
8893        assert_eq!(doc.content[0].node_type, "layoutSection");
8894        let columns = doc.content[0].content.as_ref().unwrap();
8895        assert_eq!(columns.len(), 2);
8896        assert_eq!(columns[0].node_type, "layoutColumn");
8897        assert_eq!(columns[1].node_type, "layoutColumn");
8898    }
8899
8900    #[test]
8901    fn adf_layout_to_markdown() {
8902        let doc = AdfDocument {
8903            version: 1,
8904            doc_type: "doc".to_string(),
8905            content: vec![AdfNode::layout_section(vec![
8906                AdfNode::layout_column(50, vec![AdfNode::paragraph(vec![AdfNode::text("Left.")])]),
8907                AdfNode::layout_column(50, vec![AdfNode::paragraph(vec![AdfNode::text("Right.")])]),
8908            ])],
8909        };
8910        let md = adf_to_markdown(&doc).unwrap();
8911        assert!(md.contains("::::layout"));
8912        assert!(md.contains(":::column{width=50}"));
8913        assert!(md.contains("Left."));
8914        assert!(md.contains("Right."));
8915    }
8916
8917    #[test]
8918    fn layout_column_localid_roundtrip() {
8919        let adf_json = r#"{
8920            "version": 1,
8921            "type": "doc",
8922            "content": [{
8923                "type": "layoutSection",
8924                "content": [
8925                    {
8926                        "type": "layoutColumn",
8927                        "attrs": {"width": 50.0, "localId": "aabb112233cc"},
8928                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Left"}]}]
8929                    },
8930                    {
8931                        "type": "layoutColumn",
8932                        "attrs": {"width": 50.0, "localId": "ddeeff445566"},
8933                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Right"}]}]
8934                    }
8935                ]
8936            }]
8937        }"#;
8938        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8939        let md = adf_to_markdown(&doc).unwrap();
8940        assert!(
8941            md.contains("localId=aabb112233cc"),
8942            "first column localId should appear in markdown: {md}"
8943        );
8944        assert!(
8945            md.contains("localId=ddeeff445566"),
8946            "second column localId should appear in markdown: {md}"
8947        );
8948        let rt = markdown_to_adf(&md).unwrap();
8949        let cols = rt.content[0].content.as_ref().unwrap();
8950        assert_eq!(
8951            cols[0].attrs.as_ref().unwrap()["localId"],
8952            "aabb112233cc",
8953            "first column localId should round-trip"
8954        );
8955        assert_eq!(
8956            cols[1].attrs.as_ref().unwrap()["localId"],
8957            "ddeeff445566",
8958            "second column localId should round-trip"
8959        );
8960    }
8961
8962    #[test]
8963    fn layout_column_without_localid() {
8964        let md =
8965            "::::layout\n:::column{width=50}\nLeft.\n:::\n:::column{width=50}\nRight.\n:::\n::::";
8966        let doc = markdown_to_adf(md).unwrap();
8967        let cols = doc.content[0].content.as_ref().unwrap();
8968        assert!(
8969            cols[0].attrs.as_ref().unwrap().get("localId").is_none(),
8970            "column without localId should not gain one"
8971        );
8972        let md2 = adf_to_markdown(&doc).unwrap();
8973        assert!(
8974            !md2.contains("localId"),
8975            "no localId should appear in output: {md2}"
8976        );
8977    }
8978
8979    #[test]
8980    fn layout_column_localid_stripped_when_option_set() {
8981        let adf_json = r#"{
8982            "version": 1,
8983            "type": "doc",
8984            "content": [{
8985                "type": "layoutSection",
8986                "content": [{
8987                    "type": "layoutColumn",
8988                    "attrs": {"width": 50.0, "localId": "aabb112233cc"},
8989                    "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Col"}]}]
8990                }]
8991            }]
8992        }"#;
8993        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8994        let opts = RenderOptions {
8995            strip_local_ids: true,
8996            ..Default::default()
8997        };
8998        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
8999        assert!(!md.contains("localId"), "localId should be stripped: {md}");
9000    }
9001
9002    #[test]
9003    fn layout_column_localid_flush_previous() {
9004        // Columns open without explicit `:::` close → flush-previous path
9005        let md = "::::layout\n:::column{width=50 localId=aabb112233cc}\nLeft.\n:::column{width=50 localId=ddeeff445566}\nRight.\n:::\n::::";
9006        let doc = markdown_to_adf(md).unwrap();
9007        let cols = doc.content[0].content.as_ref().unwrap();
9008        assert_eq!(
9009            cols[0].attrs.as_ref().unwrap()["localId"],
9010            "aabb112233cc",
9011            "flush-previous column should preserve localId"
9012        );
9013        assert_eq!(
9014            cols[1].attrs.as_ref().unwrap()["localId"],
9015            "ddeeff445566",
9016            "second column localId should be preserved"
9017        );
9018    }
9019
9020    #[test]
9021    fn layout_column_localid_flush_last() {
9022        // Layout with no closing fence → column never explicitly closed → flush-last path
9023        let md = "::::layout\n:::column{width=50 localId=aabb112233cc}\nOnly column.";
9024        let doc = markdown_to_adf(md).unwrap();
9025        let cols = doc.content[0].content.as_ref().unwrap();
9026        assert_eq!(
9027            cols[0].attrs.as_ref().unwrap()["localId"],
9028            "aabb112233cc",
9029            "flush-last column should preserve localId"
9030        );
9031    }
9032
9033    /// Issue #555: `layoutColumn` fractional `width` must round-trip byte-for-byte.
9034    #[test]
9035    fn issue_555_layout_column_fractional_width_roundtrip() {
9036        let adf_json = r#"{
9037            "version": 1,
9038            "type": "doc",
9039            "content": [{
9040                "type": "layoutSection",
9041                "content": [
9042                    {
9043                        "type": "layoutColumn",
9044                        "attrs": {"width": 66.66},
9045                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Wide"}]}]
9046                    },
9047                    {
9048                        "type": "layoutColumn",
9049                        "attrs": {"width": 33.34},
9050                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Narrow"}]}]
9051                    }
9052                ]
9053            }]
9054        }"#;
9055        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9056        let md = adf_to_markdown(&doc).unwrap();
9057        assert!(md.contains("width=66.66"), "fractional width in md: {md}");
9058        assert!(md.contains("width=33.34"), "fractional width in md: {md}");
9059        let rt = markdown_to_adf(&md).unwrap();
9060        let cols = rt.content[0].content.as_ref().unwrap();
9061        assert_eq!(cols[0].attrs.as_ref().unwrap()["width"], 66.66);
9062        assert_eq!(cols[1].attrs.as_ref().unwrap()["width"], 33.34);
9063    }
9064
9065    /// Issue #555: `layoutColumn` 5/6 widths (`83.33`) round-trip without precision loss.
9066    #[test]
9067    fn issue_555_layout_column_five_sixths_width_roundtrip() {
9068        let adf_json = r#"{
9069            "version": 1,
9070            "type": "doc",
9071            "content": [{
9072                "type": "layoutSection",
9073                "content": [
9074                    {
9075                        "type": "layoutColumn",
9076                        "attrs": {"width": 83.33},
9077                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Wide"}]}]
9078                    },
9079                    {
9080                        "type": "layoutColumn",
9081                        "attrs": {"width": 16.67},
9082                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Narrow"}]}]
9083                    }
9084                ]
9085            }]
9086        }"#;
9087        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9088        let md = adf_to_markdown(&doc).unwrap();
9089        let rt = markdown_to_adf(&md).unwrap();
9090        let cols = rt.content[0].content.as_ref().unwrap();
9091        assert_eq!(cols[0].attrs.as_ref().unwrap()["width"], 83.33);
9092        assert_eq!(cols[1].attrs.as_ref().unwrap()["width"], 16.67);
9093    }
9094
9095    /// Issue #555: `layoutColumn` integer widths must NOT be coerced to floats on round-trip.
9096    #[test]
9097    fn issue_555_layout_column_integer_width_preserved() {
9098        let adf_json = r#"{
9099            "version": 1,
9100            "type": "doc",
9101            "content": [{
9102                "type": "layoutSection",
9103                "content": [
9104                    {
9105                        "type": "layoutColumn",
9106                        "attrs": {"width": 50},
9107                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "A"}]}]
9108                    },
9109                    {
9110                        "type": "layoutColumn",
9111                        "attrs": {"width": 50},
9112                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "B"}]}]
9113                    }
9114                ]
9115            }]
9116        }"#;
9117        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9118        let md = adf_to_markdown(&doc).unwrap();
9119        assert!(
9120            md.contains("width=50") && !md.contains("width=50."),
9121            "integer width should render without decimal: {md}"
9122        );
9123        let rt = markdown_to_adf(&md).unwrap();
9124        let cols = rt.content[0].content.as_ref().unwrap();
9125        let w0 = &cols[0].attrs.as_ref().unwrap()["width"];
9126        assert!(
9127            w0.is_i64() || w0.is_u64(),
9128            "width should remain a JSON integer, got: {w0}"
9129        );
9130        assert_eq!(w0.as_i64(), Some(50));
9131    }
9132
9133    /// Issue #555: `mediaSingle` integer `width` must NOT be coerced to a float on round-trip.
9134    #[test]
9135    fn issue_555_media_single_integer_width_preserved() {
9136        let adf_json = r#"{
9137            "version": 1,
9138            "type": "doc",
9139            "content": [{
9140                "type": "mediaSingle",
9141                "attrs": {"layout": "center", "width": 800},
9142                "content": [
9143                    {"type": "media", "attrs": {"type": "external", "url": "https://example.com/image.png"}}
9144                ]
9145            }]
9146        }"#;
9147        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9148        let md = adf_to_markdown(&doc).unwrap();
9149        assert!(
9150            md.contains("width=800") && !md.contains("width=800."),
9151            "integer width should render without decimal: {md}"
9152        );
9153        let rt = markdown_to_adf(&md).unwrap();
9154        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9155        let w = &ms_attrs["width"];
9156        assert!(
9157            w.is_i64() || w.is_u64(),
9158            "mediaSingle width should remain JSON integer, got: {w}"
9159        );
9160        assert_eq!(w.as_i64(), Some(800));
9161    }
9162
9163    /// Issue #555 (follow-up): fractional `mediaSingle` width (e.g. `66.5`, a
9164    /// percentage-based size common in Jira layouts) must survive `from-adf`
9165    /// instead of being silently dropped.
9166    #[test]
9167    fn issue_555_media_single_fractional_width_preserved() {
9168        let adf_json = r#"{
9169            "version": 1,
9170            "type": "doc",
9171            "content": [{
9172                "type": "mediaSingle",
9173                "attrs": {"layout": "center", "width": 66.5},
9174                "content": [
9175                    {"type": "media", "attrs": {"type": "external", "url": "https://example.com/diagram.png"}}
9176                ]
9177            }]
9178        }"#;
9179        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9180        let md = adf_to_markdown(&doc).unwrap();
9181        assert!(
9182            md.contains("width=66.5"),
9183            "fractional width must appear in JFM: {md}"
9184        );
9185        let rt = markdown_to_adf(&md).unwrap();
9186        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9187        assert_eq!(ms_attrs["width"], 66.5);
9188    }
9189
9190    /// Issue #555: `mediaSingle` float `width` must not be dropped during ADF→JFM→ADF.
9191    #[test]
9192    fn issue_555_media_single_float_width_preserved() {
9193        let adf_json = r#"{
9194            "version": 1,
9195            "type": "doc",
9196            "content": [{
9197                "type": "mediaSingle",
9198                "attrs": {"layout": "center", "width": 800.0},
9199                "content": [
9200                    {"type": "media", "attrs": {"type": "external", "url": "https://example.com/image.png"}}
9201                ]
9202            }]
9203        }"#;
9204        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9205        let md = adf_to_markdown(&doc).unwrap();
9206        assert!(
9207            md.contains("width=800.0"),
9208            "float width should render with decimal: {md}"
9209        );
9210        let rt = markdown_to_adf(&md).unwrap();
9211        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9212        let w = &ms_attrs["width"];
9213        assert!(
9214            w.is_f64(),
9215            "mediaSingle float width should stay a JSON float, got: {w}"
9216        );
9217        assert_eq!(w.as_f64(), Some(800.0));
9218    }
9219
9220    /// Issue #555: `mediaSingle` with `layout=wide` and integer width must round-trip.
9221    #[test]
9222    fn issue_555_media_single_wide_layout_integer_width_roundtrip() {
9223        let adf_json = r#"{
9224            "version": 1,
9225            "type": "doc",
9226            "content": [{
9227                "type": "mediaSingle",
9228                "attrs": {"layout": "wide", "width": 1420},
9229                "content": [
9230                    {"type": "media", "attrs": {"type": "external", "url": "https://ex.com/x.png"}}
9231                ]
9232            }]
9233        }"#;
9234        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9235        let md = adf_to_markdown(&doc).unwrap();
9236        let rt = markdown_to_adf(&md).unwrap();
9237        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9238        assert_eq!(ms_attrs["layout"], "wide");
9239        let w = &ms_attrs["width"];
9240        assert!(
9241            w.is_i64() || w.is_u64(),
9242            "mediaSingle width should remain JSON integer, got: {w}"
9243        );
9244        assert_eq!(w.as_i64(), Some(1420));
9245    }
9246
9247    /// Issue #555: Confluence file-attachment `mediaSingle` with integer `mediaWidth`
9248    /// must round-trip without float coercion.
9249    #[test]
9250    fn issue_555_file_media_single_integer_width_preserved() {
9251        let adf_json = r#"{
9252            "version": 1,
9253            "type": "doc",
9254            "content": [{
9255                "type": "mediaSingle",
9256                "attrs": {"layout": "wide", "width": 1420},
9257                "content": [
9258                    {"type": "media", "attrs": {"id": "abc-123", "type": "file", "collection": "col-1", "width": 1200, "height": 800}}
9259                ]
9260            }]
9261        }"#;
9262        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9263        let md = adf_to_markdown(&doc).unwrap();
9264        let rt = markdown_to_adf(&md).unwrap();
9265        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9266        let ms_w = &ms_attrs["width"];
9267        assert!(ms_w.is_i64() || ms_w.is_u64(), "ms width: {ms_w}");
9268        assert_eq!(ms_w.as_i64(), Some(1420));
9269        let media = &rt.content[0].content.as_ref().unwrap()[0];
9270        let media_attrs = media.attrs.as_ref().unwrap();
9271        let mw = &media_attrs["width"];
9272        assert!(mw.is_i64() || mw.is_u64(), "media width: {mw}");
9273        assert_eq!(mw.as_i64(), Some(1200));
9274        let mh = &media_attrs["height"];
9275        assert!(mh.is_i64() || mh.is_u64(), "media height: {mh}");
9276        assert_eq!(mh.as_i64(), Some(800));
9277    }
9278
9279    /// Issue #555: `fmt_numeric_attr` preserves the original integer/float JSON type.
9280    #[test]
9281    fn issue_555_fmt_numeric_attr_preserves_type() {
9282        assert_eq!(
9283            fmt_numeric_attr(&serde_json::json!(50)).as_deref(),
9284            Some("50")
9285        );
9286        assert_eq!(
9287            fmt_numeric_attr(&serde_json::json!(50.0)).as_deref(),
9288            Some("50.0")
9289        );
9290        assert_eq!(
9291            fmt_numeric_attr(&serde_json::json!(66.66)).as_deref(),
9292            Some("66.66")
9293        );
9294        assert_eq!(
9295            fmt_numeric_attr(&serde_json::json!(-5)).as_deref(),
9296            Some("-5")
9297        );
9298        assert_eq!(fmt_numeric_attr(&serde_json::json!("not a number")), None);
9299        // u64 values above i64::MAX exercise the u64-only branch.
9300        let big = serde_json::Value::Number(serde_json::Number::from(u64::MAX));
9301        assert_eq!(
9302            fmt_numeric_attr(&big).as_deref(),
9303            Some("18446744073709551615")
9304        );
9305        // Null is not a number.
9306        assert_eq!(fmt_numeric_attr(&serde_json::Value::Null), None);
9307    }
9308
9309    /// Issue #555: `parse_numeric_attr` distinguishes integer vs float strings.
9310    #[test]
9311    fn issue_555_parse_numeric_attr_detects_type() {
9312        let v = parse_numeric_attr("800").unwrap();
9313        assert!(v.is_i64() || v.is_u64(), "'800' should parse as integer");
9314        assert_eq!(v.as_i64(), Some(800));
9315
9316        let v = parse_numeric_attr("800.0").unwrap();
9317        assert!(v.is_f64(), "'800.0' should parse as float");
9318        assert_eq!(v.as_f64(), Some(800.0));
9319
9320        let v = parse_numeric_attr("66.66").unwrap();
9321        assert!(v.is_f64());
9322        assert_eq!(v.as_f64(), Some(66.66));
9323
9324        // Scientific notation is treated as float (matches JSON semantics).
9325        let v = parse_numeric_attr("1e2").unwrap();
9326        assert!(v.is_f64());
9327        assert_eq!(v.as_f64(), Some(100.0));
9328        let v = parse_numeric_attr("1E2").unwrap();
9329        assert!(v.is_f64());
9330        assert_eq!(v.as_f64(), Some(100.0));
9331
9332        // Negative integer.
9333        let v = parse_numeric_attr("-42").unwrap();
9334        assert!(v.is_i64());
9335        assert_eq!(v.as_i64(), Some(-42));
9336
9337        assert!(parse_numeric_attr("not-a-number").is_none());
9338        assert!(parse_numeric_attr("").is_none());
9339        assert!(parse_numeric_attr("1.2.3").is_none());
9340    }
9341
9342    /// Issue #555: fractional `mediaSingle` width with non-default `layout=wide`
9343    /// must preserve both the layout and the fractional width through round-trip.
9344    #[test]
9345    fn issue_555_media_single_wide_layout_fractional_width_roundtrip() {
9346        let adf_json = r#"{
9347            "version": 1,
9348            "type": "doc",
9349            "content": [{
9350                "type": "mediaSingle",
9351                "attrs": {"layout": "wide", "width": 83.33},
9352                "content": [
9353                    {"type": "media", "attrs": {"type": "external", "url": "https://ex.com/x.png"}}
9354                ]
9355            }]
9356        }"#;
9357        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9358        let md = adf_to_markdown(&doc).unwrap();
9359        assert!(md.contains("layout=wide"), "layout must appear in md: {md}");
9360        assert!(md.contains("width=83.33"), "width must appear in md: {md}");
9361        let rt = markdown_to_adf(&md).unwrap();
9362        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9363        assert_eq!(ms_attrs["layout"], "wide");
9364        assert_eq!(ms_attrs["width"], 83.33);
9365    }
9366
9367    /// Issue #555: fractional `mediaWidth` on a Confluence file-attachment
9368    /// `mediaSingle` must round-trip (exercises the file-branch `mediaWidth`
9369    /// render path, which previously used `as_u64` and silently dropped floats).
9370    #[test]
9371    fn issue_555_file_media_single_fractional_media_width_preserved() {
9372        let adf_json = r#"{
9373            "version": 1,
9374            "type": "doc",
9375            "content": [{
9376                "type": "mediaSingle",
9377                "attrs": {"layout": "wide", "width": 66.5},
9378                "content": [
9379                    {"type": "media", "attrs": {"id": "abc", "type": "file", "collection": "c"}}
9380                ]
9381            }]
9382        }"#;
9383        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9384        let md = adf_to_markdown(&doc).unwrap();
9385        assert!(md.contains("mediaWidth=66.5"), "mediaWidth in md: {md}");
9386        let rt = markdown_to_adf(&md).unwrap();
9387        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9388        assert_eq!(ms_attrs["width"], 66.5);
9389    }
9390
9391    /// Issue #555: fractional inner `media` width/height on a file attachment
9392    /// must round-trip (exercises the file-branch inner `width`/`height` render
9393    /// path, which previously used `as_u64` and silently dropped floats).
9394    #[test]
9395    fn issue_555_file_media_fractional_inner_dimensions_preserved() {
9396        let adf_json = r#"{
9397            "version": 1,
9398            "type": "doc",
9399            "content": [{
9400                "type": "mediaSingle",
9401                "attrs": {"layout": "center"},
9402                "content": [
9403                    {"type": "media", "attrs": {"id": "abc", "type": "file", "collection": "c", "width": 1200.5, "height": 800.25}}
9404                ]
9405            }]
9406        }"#;
9407        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9408        let md = adf_to_markdown(&doc).unwrap();
9409        assert!(md.contains("width=1200.5"), "width in md: {md}");
9410        assert!(md.contains("height=800.25"), "height in md: {md}");
9411        let rt = markdown_to_adf(&md).unwrap();
9412        let media = &rt.content[0].content.as_ref().unwrap()[0];
9413        let attrs = media.attrs.as_ref().unwrap();
9414        assert_eq!(attrs["width"], 1200.5);
9415        assert_eq!(attrs["height"], 800.25);
9416    }
9417
9418    #[test]
9419    fn decisions_list() {
9420        let md = ":::decisions\n- <> Use PostgreSQL\n- <> REST API\n:::";
9421        let doc = markdown_to_adf(md).unwrap();
9422        assert_eq!(doc.content[0].node_type, "decisionList");
9423        let items = doc.content[0].content.as_ref().unwrap();
9424        assert_eq!(items.len(), 2);
9425        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DECIDED");
9426    }
9427
9428    // decisionItem is inline-only per ADF schema — its content must be
9429    // text/inline nodes, not a paragraph wrapper (issue #753).
9430    #[test]
9431    fn decision_item_content_is_inline_not_paragraph() {
9432        let md = ":::decisions\n- <> Use Rust\n:::";
9433        let doc = markdown_to_adf(md).unwrap();
9434        let items = doc.content[0].content.as_ref().unwrap();
9435        let first_child = &items[0].content.as_ref().unwrap()[0];
9436        assert_eq!(
9437            first_child.node_type, "text",
9438            "decisionItem must contain inline nodes directly, not a paragraph wrapper"
9439        );
9440        assert_eq!(first_child.text.as_deref(), Some("Use Rust"));
9441    }
9442
9443    #[test]
9444    fn adf_decisions_to_markdown() {
9445        let doc = AdfDocument {
9446            version: 1,
9447            doc_type: "doc".to_string(),
9448            content: vec![AdfNode::decision_list(vec![AdfNode::decision_item(
9449                "DECIDED",
9450                vec![AdfNode::paragraph(vec![AdfNode::text("Use PostgreSQL")])],
9451            )])],
9452        };
9453        let md = adf_to_markdown(&doc).unwrap();
9454        assert!(md.contains(":::decisions"));
9455        assert!(md.contains("- <> Use PostgreSQL"));
9456    }
9457
9458    #[test]
9459    fn bodied_extension_container() {
9460        let md = ":::extension{type=com.forge key=my-macro}\nContent.\n:::";
9461        let doc = markdown_to_adf(md).unwrap();
9462        assert_eq!(doc.content[0].node_type, "bodiedExtension");
9463        assert_eq!(
9464            doc.content[0].attrs.as_ref().unwrap()["extensionType"],
9465            "com.forge"
9466        );
9467    }
9468
9469    #[test]
9470    fn adf_bodied_extension_to_markdown() {
9471        let doc = AdfDocument {
9472            version: 1,
9473            doc_type: "doc".to_string(),
9474            content: vec![AdfNode::bodied_extension(
9475                "com.forge",
9476                "my-macro",
9477                vec![AdfNode::paragraph(vec![AdfNode::text("Content.")])],
9478            )],
9479        };
9480        let md = adf_to_markdown(&doc).unwrap();
9481        assert!(md.contains(":::extension{type=com.forge key=my-macro}"));
9482        assert!(md.contains("Content."));
9483    }
9484
9485    // ── Leaf block directive tests (Tier 3) ──────────────────────────
9486
9487    #[test]
9488    fn leaf_block_card() {
9489        let doc = markdown_to_adf("::card[https://example.com/browse/PROJ-123]").unwrap();
9490        assert_eq!(doc.content[0].node_type, "blockCard");
9491        assert_eq!(
9492            doc.content[0].attrs.as_ref().unwrap()["url"],
9493            "https://example.com/browse/PROJ-123"
9494        );
9495    }
9496
9497    #[test]
9498    fn adf_block_card_to_markdown() {
9499        let doc = AdfDocument {
9500            version: 1,
9501            doc_type: "doc".to_string(),
9502            content: vec![AdfNode::block_card("https://example.com/browse/PROJ-123")],
9503        };
9504        let md = adf_to_markdown(&doc).unwrap();
9505        assert!(md.contains("::card[https://example.com/browse/PROJ-123]"));
9506    }
9507
9508    #[test]
9509    fn round_trip_block_card() {
9510        let md = "::card[https://example.com/browse/PROJ-123]\n";
9511        let doc = markdown_to_adf(md).unwrap();
9512        let result = adf_to_markdown(&doc).unwrap();
9513        assert!(result.contains("::card[https://example.com/browse/PROJ-123]"));
9514    }
9515
9516    #[test]
9517    fn leaf_embed_card() {
9518        let doc =
9519            markdown_to_adf("::embed[https://figma.com/file/abc]{layout=wide width=80}").unwrap();
9520        assert_eq!(doc.content[0].node_type, "embedCard");
9521        let attrs = doc.content[0].attrs.as_ref().unwrap();
9522        assert_eq!(attrs["url"], "https://figma.com/file/abc");
9523        assert_eq!(attrs["layout"], "wide");
9524        assert_eq!(attrs["width"], 80.0);
9525    }
9526
9527    #[test]
9528    fn leaf_embed_card_with_original_height() {
9529        let doc = markdown_to_adf(
9530            "::embed[https://example.com]{layout=center originalHeight=732 width=100}",
9531        )
9532        .unwrap();
9533        assert_eq!(doc.content[0].node_type, "embedCard");
9534        let attrs = doc.content[0].attrs.as_ref().unwrap();
9535        assert_eq!(attrs["url"], "https://example.com");
9536        assert_eq!(attrs["layout"], "center");
9537        assert_eq!(attrs["originalHeight"], 732.0);
9538        assert_eq!(attrs["width"], 100.0);
9539    }
9540
9541    #[test]
9542    fn adf_embed_card_to_markdown() {
9543        let doc = AdfDocument {
9544            version: 1,
9545            doc_type: "doc".to_string(),
9546            content: vec![AdfNode::embed_card(
9547                "https://figma.com/file/abc",
9548                Some("wide"),
9549                None,
9550                Some(80.0),
9551            )],
9552        };
9553        let md = adf_to_markdown(&doc).unwrap();
9554        assert!(md.contains("::embed[https://figma.com/file/abc]{layout=wide width=80}"));
9555    }
9556
9557    #[test]
9558    fn adf_embed_card_original_height_to_markdown() {
9559        let doc = AdfDocument {
9560            version: 1,
9561            doc_type: "doc".to_string(),
9562            content: vec![AdfNode::embed_card(
9563                "https://example.com",
9564                Some("center"),
9565                Some(732.0),
9566                Some(100.0),
9567            )],
9568        };
9569        let md = adf_to_markdown(&doc).unwrap();
9570        assert!(
9571            md.contains("::embed[https://example.com]{layout=center originalHeight=732 width=100}"),
9572            "expected originalHeight and width in md: {md}"
9573        );
9574    }
9575
9576    #[test]
9577    fn embed_card_roundtrip_with_all_attrs() {
9578        let adf_json = r#"{"version":1,"type":"doc","content":[{
9579            "type":"embedCard",
9580            "attrs":{"layout":"center","originalHeight":732.0,"url":"https://example.com","width":100.0}
9581        }]}"#;
9582        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9583        let md = adf_to_markdown(&doc).unwrap();
9584        assert!(
9585            md.contains("originalHeight=732"),
9586            "originalHeight missing from md: {md}"
9587        );
9588        assert!(md.contains("width=100"), "width missing from md: {md}");
9589        let rt = markdown_to_adf(&md).unwrap();
9590        let attrs = rt.content[0].attrs.as_ref().unwrap();
9591        assert_eq!(attrs["originalHeight"], 732.0);
9592        assert_eq!(attrs["width"], 100.0);
9593        assert_eq!(attrs["layout"], "center");
9594        assert_eq!(attrs["url"], "https://example.com");
9595    }
9596
9597    #[test]
9598    fn embed_card_fractional_dimensions() {
9599        let doc = AdfDocument {
9600            version: 1,
9601            doc_type: "doc".to_string(),
9602            content: vec![AdfNode::embed_card(
9603                "https://example.com",
9604                Some("center"),
9605                Some(732.5),
9606                Some(99.9),
9607            )],
9608        };
9609        let md = adf_to_markdown(&doc).unwrap();
9610        assert!(
9611            md.contains("originalHeight=732.5"),
9612            "fractional originalHeight missing: {md}"
9613        );
9614        assert!(md.contains("width=99.9"), "fractional width missing: {md}");
9615        let rt = markdown_to_adf(&md).unwrap();
9616        let attrs = rt.content[0].attrs.as_ref().unwrap();
9617        assert_eq!(attrs["originalHeight"], 732.5);
9618        assert_eq!(attrs["width"], 99.9);
9619    }
9620
9621    #[test]
9622    fn embed_card_integer_width_in_json() {
9623        // JSON integer (not float) should also be extracted via as_f64()
9624        let adf_json = r#"{"version":1,"type":"doc","content":[{
9625            "type":"embedCard",
9626            "attrs":{"url":"https://example.com","width":100}
9627        }]}"#;
9628        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9629        let md = adf_to_markdown(&doc).unwrap();
9630        assert!(
9631            md.contains("width=100"),
9632            "integer width missing from md: {md}"
9633        );
9634        let rt = markdown_to_adf(&md).unwrap();
9635        assert_eq!(rt.content[0].attrs.as_ref().unwrap()["width"], 100.0);
9636    }
9637
9638    #[test]
9639    fn embed_card_only_original_height() {
9640        // originalHeight without width
9641        let adf_json = r#"{"version":1,"type":"doc","content":[{
9642            "type":"embedCard",
9643            "attrs":{"url":"https://example.com","originalHeight":500.0}
9644        }]}"#;
9645        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9646        let md = adf_to_markdown(&doc).unwrap();
9647        assert!(
9648            md.contains("originalHeight=500"),
9649            "originalHeight missing: {md}"
9650        );
9651        assert!(!md.contains("width="), "width should not appear: {md}");
9652        let rt = markdown_to_adf(&md).unwrap();
9653        let attrs = rt.content[0].attrs.as_ref().unwrap();
9654        assert_eq!(attrs["originalHeight"], 500.0);
9655        assert!(attrs.get("width").is_none());
9656    }
9657
9658    #[test]
9659    fn leaf_void_extension() {
9660        let doc = markdown_to_adf("::extension{type=com.atlassian.macro key=jira-chart}").unwrap();
9661        assert_eq!(doc.content[0].node_type, "extension");
9662        assert_eq!(
9663            doc.content[0].attrs.as_ref().unwrap()["extensionType"],
9664            "com.atlassian.macro"
9665        );
9666        assert_eq!(
9667            doc.content[0].attrs.as_ref().unwrap()["extensionKey"],
9668            "jira-chart"
9669        );
9670    }
9671
9672    #[test]
9673    fn adf_void_extension_to_markdown() {
9674        let doc = AdfDocument {
9675            version: 1,
9676            doc_type: "doc".to_string(),
9677            content: vec![AdfNode::extension(
9678                "com.atlassian.macro",
9679                "jira-chart",
9680                None,
9681            )],
9682        };
9683        let md = adf_to_markdown(&doc).unwrap();
9684        assert!(md.contains("::extension{type=com.atlassian.macro key=jira-chart}"));
9685    }
9686
9687    // ── Bare URL autolink tests ──────────────────────────────────────
9688
9689    #[test]
9690    fn bare_url_autolink() {
9691        let doc = markdown_to_adf("Visit https://example.com today").unwrap();
9692        let content = doc.content[0].content.as_ref().unwrap();
9693        assert_eq!(content[0].text.as_deref(), Some("Visit "));
9694        assert_eq!(content[1].node_type, "inlineCard");
9695        assert_eq!(
9696            content[1].attrs.as_ref().unwrap()["url"],
9697            "https://example.com"
9698        );
9699        assert_eq!(content[2].text.as_deref(), Some(" today"));
9700    }
9701
9702    #[test]
9703    fn bare_url_strips_trailing_punctuation() {
9704        let doc = markdown_to_adf("See https://example.com.").unwrap();
9705        let content = doc.content[0].content.as_ref().unwrap();
9706        assert_eq!(
9707            content[1].attrs.as_ref().unwrap()["url"],
9708            "https://example.com"
9709        );
9710    }
9711
9712    #[test]
9713    fn bare_url_round_trip() {
9714        let doc = markdown_to_adf("Visit https://example.com/path today").unwrap();
9715        let md = adf_to_markdown(&doc).unwrap();
9716        assert!(md.contains(":card[https://example.com/path]"));
9717    }
9718
9719    // ── Issue #475: plain-text URL must not become inlineCard ─────────
9720
9721    #[test]
9722    fn plain_text_url_round_trips_as_text() {
9723        // A text node whose content is a bare URL (no link mark) must
9724        // survive ADF→JFM→ADF as a text node, not an inlineCard.
9725        let adf_json = r#"{
9726            "version": 1,
9727            "type": "doc",
9728            "content": [{
9729                "type": "paragraph",
9730                "content": [
9731                    {"type": "text", "text": "https://example.com/some/path/to/resource"}
9732                ]
9733            }]
9734        }"#;
9735        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9736        let jfm = adf_to_markdown(&adf).unwrap();
9737        let roundtripped = markdown_to_adf(&jfm).unwrap();
9738        let content = roundtripped.content[0].content.as_ref().unwrap();
9739        assert_eq!(content.len(), 1, "should be a single node");
9740        assert_eq!(content[0].node_type, "text");
9741        assert_eq!(
9742            content[0].text.as_deref(),
9743            Some("https://example.com/some/path/to/resource")
9744        );
9745    }
9746
9747    #[test]
9748    fn url_text_with_link_mark_round_trips_as_text_node() {
9749        // Issue #523: A text node whose content is a URL with a link mark
9750        // (href differs by trailing slash) must round-trip as text+link,
9751        // not become an inlineCard.
9752        let adf_json = r#"{
9753            "version": 1,
9754            "type": "doc",
9755            "content": [{
9756                "type": "paragraph",
9757                "content": [{
9758                    "type": "text",
9759                    "text": "https://octopz.example.com",
9760                    "marks": [{"type": "link", "attrs": {"href": "https://octopz.example.com/"}}]
9761                }]
9762            }]
9763        }"#;
9764        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9765        let jfm = adf_to_markdown(&adf).unwrap();
9766        let roundtripped = markdown_to_adf(&jfm).unwrap();
9767        let content = roundtripped.content[0].content.as_ref().unwrap();
9768        assert_eq!(content.len(), 1, "should be a single node");
9769        assert_eq!(content[0].node_type, "text", "must be text, not inlineCard");
9770        assert_eq!(
9771            content[0].text.as_deref(),
9772            Some("https://octopz.example.com")
9773        );
9774        let mark = &content[0].marks.as_ref().unwrap()[0];
9775        assert_eq!(mark.mark_type, "link");
9776        assert_eq!(
9777            mark.attrs.as_ref().unwrap()["href"],
9778            "https://octopz.example.com/"
9779        );
9780    }
9781
9782    #[test]
9783    fn url_text_with_exact_link_mark_round_trips() {
9784        // Variant: text and href are identical (no trailing slash difference).
9785        let adf_json = r#"{
9786            "version": 1,
9787            "type": "doc",
9788            "content": [{
9789                "type": "paragraph",
9790                "content": [{
9791                    "type": "text",
9792                    "text": "https://example.com/path",
9793                    "marks": [{"type": "link", "attrs": {"href": "https://example.com/path"}}]
9794                }]
9795            }]
9796        }"#;
9797        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9798        let jfm = adf_to_markdown(&adf).unwrap();
9799        let roundtripped = markdown_to_adf(&jfm).unwrap();
9800        let content = roundtripped.content[0].content.as_ref().unwrap();
9801        assert_eq!(content.len(), 1, "should be a single node");
9802        assert_eq!(content[0].node_type, "text");
9803        assert_eq!(content[0].text.as_deref(), Some("https://example.com/path"));
9804        let mark = &content[0].marks.as_ref().unwrap()[0];
9805        assert_eq!(mark.mark_type, "link");
9806    }
9807
9808    #[test]
9809    fn plain_text_url_amid_text_round_trips() {
9810        // URL embedded in surrounding text, without link mark.
9811        let adf_json = r#"{
9812            "version": 1,
9813            "type": "doc",
9814            "content": [{
9815                "type": "paragraph",
9816                "content": [
9817                    {"type": "text", "text": "see https://example.com for info"}
9818                ]
9819            }]
9820        }"#;
9821        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9822        let jfm = adf_to_markdown(&adf).unwrap();
9823        let roundtripped = markdown_to_adf(&jfm).unwrap();
9824        let content = roundtripped.content[0].content.as_ref().unwrap();
9825        assert_eq!(content.len(), 1);
9826        assert_eq!(content[0].node_type, "text");
9827        assert_eq!(
9828            content[0].text.as_deref(),
9829            Some("see https://example.com for info")
9830        );
9831    }
9832
9833    #[test]
9834    fn plain_text_multiple_urls_round_trips() {
9835        let adf_json = r#"{
9836            "version": 1,
9837            "type": "doc",
9838            "content": [{
9839                "type": "paragraph",
9840                "content": [
9841                    {"type": "text", "text": "http://a.com and https://b.com"}
9842                ]
9843            }]
9844        }"#;
9845        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9846        let jfm = adf_to_markdown(&adf).unwrap();
9847        let roundtripped = markdown_to_adf(&jfm).unwrap();
9848        let content = roundtripped.content[0].content.as_ref().unwrap();
9849        assert_eq!(content.len(), 1);
9850        assert_eq!(content[0].node_type, "text");
9851        assert_eq!(
9852            content[0].text.as_deref(),
9853            Some("http://a.com and https://b.com")
9854        );
9855    }
9856
9857    #[test]
9858    fn plain_text_http_prefix_no_url_unchanged() {
9859        // "http" without "://" should not be escaped or altered.
9860        let adf_json = r#"{
9861            "version": 1,
9862            "type": "doc",
9863            "content": [{
9864                "type": "paragraph",
9865                "content": [
9866                    {"type": "text", "text": "the http header is important"}
9867                ]
9868            }]
9869        }"#;
9870        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9871        let jfm = adf_to_markdown(&adf).unwrap();
9872        let roundtripped = markdown_to_adf(&jfm).unwrap();
9873        let content = roundtripped.content[0].content.as_ref().unwrap();
9874        assert_eq!(
9875            content[0].text.as_deref(),
9876            Some("the http header is important")
9877        );
9878    }
9879
9880    #[test]
9881    fn linked_url_text_round_trips() {
9882        // A text node that is exactly a URL with a link mark pointing to the
9883        // same URL must round-trip as a single text node with a link mark
9884        // (no inlineCard, no lost/split content).
9885        let adf_json = r#"{
9886            "version": 1,
9887            "type": "doc",
9888            "content": [{
9889                "type": "paragraph",
9890                "content": [{
9891                    "type": "text",
9892                    "text": "https://example.com",
9893                    "marks": [{"type": "link", "attrs": {"href": "https://example.com"}}]
9894                }]
9895            }]
9896        }"#;
9897        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9898        let jfm = adf_to_markdown(&adf).unwrap();
9899        let roundtripped = markdown_to_adf(&jfm).unwrap();
9900        let content = roundtripped.content[0].content.as_ref().unwrap();
9901        assert_eq!(content.len(), 1);
9902        assert_eq!(content[0].node_type, "text");
9903        assert_eq!(content[0].text.as_deref(), Some("https://example.com"));
9904        let mark = &content[0].marks.as_ref().unwrap()[0];
9905        assert_eq!(mark.mark_type, "link");
9906        assert_eq!(mark.attrs.as_ref().unwrap()["href"], "https://example.com");
9907    }
9908
9909    // ── Issue #493: bracket-link ambiguity ─────────────────────────────
9910
9911    #[test]
9912    fn escape_link_brackets_unit() {
9913        assert_eq!(escape_link_brackets("hello"), "hello");
9914        assert_eq!(escape_link_brackets("["), "\\[");
9915        assert_eq!(escape_link_brackets("]"), "\\]");
9916        assert_eq!(escape_link_brackets("[PROJ-456]"), "\\[PROJ-456\\]");
9917        assert_eq!(escape_link_brackets("a[b]c"), "a\\[b\\]c");
9918    }
9919
9920    #[test]
9921    fn bracket_text_with_link_mark_escapes_brackets() {
9922        // A text node whose content is "[" with a link mark should escape
9923        // the bracket so it does not create ambiguous markdown link syntax.
9924        let doc = AdfDocument {
9925            version: 1,
9926            doc_type: "doc".to_string(),
9927            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
9928                "[",
9929                vec![AdfMark::link("https://example.com")],
9930            )])],
9931        };
9932        let md = adf_to_markdown(&doc).unwrap();
9933        assert_eq!(md.trim(), "[\\[](https://example.com)");
9934    }
9935
9936    #[test]
9937    fn bracket_text_with_link_mark_round_trips() {
9938        // Issue #493 reproducer: adjacent text nodes sharing a link mark
9939        // where the first node's content is "[".
9940        let adf_json = r#"{
9941            "type": "doc",
9942            "version": 1,
9943            "content": [{
9944                "type": "paragraph",
9945                "content": [
9946                    {
9947                        "type": "text",
9948                        "text": "[",
9949                        "marks": [{"type": "link", "attrs": {"href": "https://example.com/ticket/123"}}]
9950                    },
9951                    {
9952                        "type": "text",
9953                        "text": "PROJ-456] Fix the auth bug",
9954                        "marks": [
9955                            {"type": "underline"},
9956                            {"type": "link", "attrs": {"href": "https://example.com/ticket/123"}}
9957                        ]
9958                    }
9959                ]
9960            }]
9961        }"#;
9962        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9963        let jfm = adf_to_markdown(&adf).unwrap();
9964
9965        // The markdown should contain escaped brackets inside the link
9966        assert!(jfm.contains("\\["), "opening bracket should be escaped");
9967
9968        // Round-trip: both text nodes must survive with link marks
9969        let rt = markdown_to_adf(&jfm).unwrap();
9970        let content = rt.content[0].content.as_ref().unwrap();
9971
9972        // All text nodes that were part of the link must still carry a link mark
9973        let link_nodes: Vec<_> = content
9974            .iter()
9975            .filter(|n| {
9976                n.marks
9977                    .as_ref()
9978                    .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "link"))
9979            })
9980            .collect();
9981        assert!(
9982            !link_nodes.is_empty(),
9983            "link mark must be preserved on round-trip"
9984        );
9985
9986        // The combined text across all nodes should contain the original content
9987        let all_text: String = content.iter().filter_map(|n| n.text.as_deref()).collect();
9988        assert!(
9989            all_text.contains('['),
9990            "literal '[' must survive round-trip"
9991        );
9992        assert!(
9993            all_text.contains("PROJ-456]"),
9994            "continuation text must survive round-trip"
9995        );
9996    }
9997
9998    #[test]
9999    fn closing_bracket_in_link_text_round_trips() {
10000        // A text node containing "]" inside a link should be escaped and
10001        // survive round-trip without breaking the link syntax.
10002        let doc = AdfDocument {
10003            version: 1,
10004            doc_type: "doc".to_string(),
10005            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10006                "item]",
10007                vec![AdfMark::link("https://example.com")],
10008            )])],
10009        };
10010        let md = adf_to_markdown(&doc).unwrap();
10011        assert_eq!(md.trim(), "[item\\]](https://example.com)");
10012
10013        let rt = markdown_to_adf(&md).unwrap();
10014        let content = rt.content[0].content.as_ref().unwrap();
10015        assert_eq!(content[0].text.as_deref(), Some("item]"));
10016        assert!(content[0]
10017            .marks
10018            .as_ref()
10019            .unwrap()
10020            .iter()
10021            .any(|m| m.mark_type == "link"));
10022    }
10023
10024    #[test]
10025    fn brackets_in_link_text_round_trip() {
10026        // Text containing both [ and ] inside a link should round-trip.
10027        let doc = AdfDocument {
10028            version: 1,
10029            doc_type: "doc".to_string(),
10030            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10031                "[PROJ-123]",
10032                vec![AdfMark::link("https://example.com")],
10033            )])],
10034        };
10035        let md = adf_to_markdown(&doc).unwrap();
10036        assert_eq!(md.trim(), "[\\[PROJ-123\\]](https://example.com)");
10037
10038        let rt = markdown_to_adf(&md).unwrap();
10039        let content = rt.content[0].content.as_ref().unwrap();
10040        assert_eq!(content[0].text.as_deref(), Some("[PROJ-123]"));
10041        assert!(content[0]
10042            .marks
10043            .as_ref()
10044            .unwrap()
10045            .iter()
10046            .any(|m| m.mark_type == "link"));
10047    }
10048
10049    #[test]
10050    fn plain_text_brackets_not_escaped() {
10051        // Brackets in plain text (no link mark) must NOT be escaped.
10052        let doc = AdfDocument {
10053            version: 1,
10054            doc_type: "doc".to_string(),
10055            content: vec![AdfNode::paragraph(vec![AdfNode::text(
10056                "see [PROJ-123] for details",
10057            )])],
10058        };
10059        let md = adf_to_markdown(&doc).unwrap();
10060        assert_eq!(md.trim(), "see [PROJ-123] for details");
10061    }
10062
10063    #[test]
10064    fn link_with_no_brackets_unchanged() {
10065        // A normal link with no brackets in the text should be unaffected.
10066        let doc = AdfDocument {
10067            version: 1,
10068            doc_type: "doc".to_string(),
10069            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10070                "click here",
10071                vec![AdfMark::link("https://example.com")],
10072            )])],
10073        };
10074        let md = adf_to_markdown(&doc).unwrap();
10075        assert_eq!(md.trim(), "[click here](https://example.com)");
10076    }
10077
10078    // ── Issue #551: URL brackets in link-marked text round-trip ────────
10079
10080    #[test]
10081    fn url_with_brackets_as_link_text_round_trips() {
10082        // Issue #551: a text node whose content is a URL containing square
10083        // brackets and which carries a link mark must round-trip verbatim.
10084        // Previously the URL-as-link-text fast path preserved `\[` and `\]`
10085        // escapes in the emitted text, corrupting the text content.
10086        let href = "https://example.com/dashboard?filter[0]=active&filter[1]=pending";
10087        let doc = AdfDocument {
10088            version: 1,
10089            doc_type: "doc".to_string(),
10090            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10091                href,
10092                vec![AdfMark::link(href)],
10093            )])],
10094        };
10095        let md = adf_to_markdown(&doc).unwrap();
10096        let rt = markdown_to_adf(&md).unwrap();
10097        let content = rt.content[0].content.as_ref().unwrap();
10098        assert_eq!(content.len(), 1);
10099        assert_eq!(content[0].node_type, "text");
10100        assert_eq!(content[0].text.as_deref(), Some(href));
10101        let mark = &content[0].marks.as_ref().unwrap()[0];
10102        assert_eq!(mark.mark_type, "link");
10103        assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10104    }
10105
10106    #[test]
10107    fn url_with_brackets_embedded_in_link_text_round_trips() {
10108        // Issue #551 updated reproducer: a link-marked text node containing
10109        // both prose and an embedded URL with brackets must round-trip
10110        // without the embedded URL being detected as a bare-URL inlineCard
10111        // or the brackets terminating the link syntax early.  This mirrors
10112        // the comment reproducer which uses an ellipsis character between
10113        // the brackets and a distinct href value.
10114        let href = "https://example.com/logs?query=service%20environment%20data&from=100&to=200";
10115        let text =
10116            "See the logs: https://example.com/logs?query=service[\u{2026}]data&from=100&to=200";
10117        let doc = AdfDocument {
10118            version: 1,
10119            doc_type: "doc".to_string(),
10120            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10121                text,
10122                vec![AdfMark::link(href)],
10123            )])],
10124        };
10125        let md = adf_to_markdown(&doc).unwrap();
10126        let rt = markdown_to_adf(&md).unwrap();
10127        let content = rt.content[0].content.as_ref().unwrap();
10128        assert_eq!(content.len(), 1, "content split unexpectedly: {content:?}");
10129        assert_eq!(content[0].node_type, "text");
10130        assert_eq!(content[0].text.as_deref(), Some(text));
10131        let mark = &content[0].marks.as_ref().unwrap()[0];
10132        assert_eq!(mark.mark_type, "link");
10133        assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10134    }
10135
10136    #[test]
10137    fn url_with_brackets_plain_text_round_trips() {
10138        // Issue #551 original reproducer: plain text with an embedded URL
10139        // that contains square brackets must round-trip verbatim.
10140        let text =
10141            "See the dashboard: https://example.com/dashboard?filter[0]=active&filter[1]=pending";
10142        let doc = AdfDocument {
10143            version: 1,
10144            doc_type: "doc".to_string(),
10145            content: vec![AdfNode::paragraph(vec![AdfNode::text(text)])],
10146        };
10147        let md = adf_to_markdown(&doc).unwrap();
10148        let rt = markdown_to_adf(&md).unwrap();
10149        let content = rt.content[0].content.as_ref().unwrap();
10150        assert_eq!(content.len(), 1);
10151        assert_eq!(content[0].node_type, "text");
10152        assert_eq!(content[0].text.as_deref(), Some(text));
10153        assert!(content[0].marks.is_none());
10154    }
10155
10156    #[test]
10157    fn url_with_link_mark_embedded_no_brackets_round_trips() {
10158        // Regression guard: embedding a bare URL inside link-marked text
10159        // (no brackets) must not create an inlineCard on round-trip.
10160        let href = "https://example.com/";
10161        let text = "See https://example.com/ for more";
10162        let doc = AdfDocument {
10163            version: 1,
10164            doc_type: "doc".to_string(),
10165            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10166                text,
10167                vec![AdfMark::link(href)],
10168            )])],
10169        };
10170        let md = adf_to_markdown(&doc).unwrap();
10171        let rt = markdown_to_adf(&md).unwrap();
10172        let content = rt.content[0].content.as_ref().unwrap();
10173        assert_eq!(content.len(), 1);
10174        assert_eq!(content[0].node_type, "text");
10175        assert_eq!(content[0].text.as_deref(), Some(text));
10176        let mark = &content[0].marks.as_ref().unwrap()[0];
10177        assert_eq!(mark.mark_type, "link");
10178        assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10179    }
10180
10181    #[test]
10182    fn nested_brackets_in_link_text_round_trip() {
10183        // Regression guard: nested brackets in link-marked text must
10184        // round-trip without corrupting the content.
10185        let href = "https://x.com";
10186        let text = "foo [a[b]c] bar";
10187        let doc = AdfDocument {
10188            version: 1,
10189            doc_type: "doc".to_string(),
10190            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10191                text,
10192                vec![AdfMark::link(href)],
10193            )])],
10194        };
10195        let md = adf_to_markdown(&doc).unwrap();
10196        let rt = markdown_to_adf(&md).unwrap();
10197        let content = rt.content[0].content.as_ref().unwrap();
10198        assert_eq!(content.len(), 1);
10199        assert_eq!(content[0].node_type, "text");
10200        assert_eq!(content[0].text.as_deref(), Some(text));
10201    }
10202
10203    #[test]
10204    fn bracket_url_bracket_in_link_text_round_trips() {
10205        // Regression guard: a link-marked text containing brackets on both
10206        // sides of an embedded URL (with brackets of its own) must
10207        // round-trip intact.  This exercises interaction between the
10208        // URL-as-link-text fast path, bare-URL detection, and bracket
10209        // escape handling.
10210        let href = "https://y.com";
10211        let text = "[see https://x.com/a[0]=1 here]";
10212        let doc = AdfDocument {
10213            version: 1,
10214            doc_type: "doc".to_string(),
10215            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10216                text,
10217                vec![AdfMark::link(href)],
10218            )])],
10219        };
10220        let md = adf_to_markdown(&doc).unwrap();
10221        let rt = markdown_to_adf(&md).unwrap();
10222        let content = rt.content[0].content.as_ref().unwrap();
10223        assert_eq!(content.len(), 1);
10224        assert_eq!(content[0].node_type, "text");
10225        assert_eq!(content[0].text.as_deref(), Some(text));
10226        let mark = &content[0].marks.as_ref().unwrap()[0];
10227        assert_eq!(mark.mark_type, "link");
10228        assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10229    }
10230
10231    #[test]
10232    fn escape_bare_urls_applied_inside_link_text() {
10233        // White-box: when a text node carries a link mark, bare URLs in the
10234        // text must still be escaped with `\h` so the parser does not
10235        // auto-link them into an inlineCard inside the link.  Without this,
10236        // round-trip of link-marked prose containing an embedded URL
10237        // silently corrupts on re-parse (issue #551).
10238        let doc = AdfDocument {
10239            version: 1,
10240            doc_type: "doc".to_string(),
10241            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10242                "See https://example.com/",
10243                vec![AdfMark::link("https://target.example.com/")],
10244            )])],
10245        };
10246        let md = adf_to_markdown(&doc).unwrap();
10247        assert!(
10248            md.contains(r"\https://example.com/"),
10249            "bare URL inside link text must be escaped, got: {md}"
10250        );
10251    }
10252
10253    #[test]
10254    fn inline_card_still_round_trips() {
10255        // An actual inlineCard node should still round-trip correctly
10256        // (it uses :card[url] syntax, not bare URL).
10257        let adf_json = r#"{
10258            "version": 1,
10259            "type": "doc",
10260            "content": [{
10261                "type": "paragraph",
10262                "content": [
10263                    {"type": "inlineCard", "attrs": {"url": "https://example.com/page"}}
10264                ]
10265            }]
10266        }"#;
10267        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10268        let jfm = adf_to_markdown(&adf).unwrap();
10269        assert!(jfm.contains(":card[https://example.com/page]"));
10270        let roundtripped = markdown_to_adf(&jfm).unwrap();
10271        let content = roundtripped.content[0].content.as_ref().unwrap();
10272        assert_eq!(content[0].node_type, "inlineCard");
10273        assert_eq!(
10274            content[0].attrs.as_ref().unwrap()["url"],
10275            "https://example.com/page"
10276        );
10277    }
10278
10279    // ── Issue #553: inlineCard round-trip with problematic URLs ───────
10280
10281    #[test]
10282    fn url_safe_in_bracket_content_balanced() {
10283        // Balanced brackets — depth never returns to zero mid-string.
10284        assert!(url_safe_in_bracket_content("https://example.com"));
10285        assert!(url_safe_in_bracket_content("https://example.com/[id]"));
10286        assert!(url_safe_in_bracket_content("a[b[c]d]e"));
10287        assert!(url_safe_in_bracket_content(""));
10288    }
10289
10290    #[test]
10291    fn url_safe_in_bracket_content_unbalanced() {
10292        // A `]` with no prior `[` would close `:card[...]` early.
10293        assert!(!url_safe_in_bracket_content("a]b"));
10294        assert!(!url_safe_in_bracket_content("https://example.com/path]end"));
10295        // Embedded newline breaks inline directive parsing.
10296        assert!(!url_safe_in_bracket_content("a\nb"));
10297    }
10298
10299    #[test]
10300    fn inline_card_url_with_closing_bracket_round_trip() {
10301        // Issue #553 defensive fix: a URL that contains `]` (unbalanced) must
10302        // round-trip without truncation.  The renderer must switch to the
10303        // quoted attribute form `:card[]{url="..."}` so the parser's
10304        // depth-based bracket matcher does not terminate the directive early.
10305        let adf_json = r#"{
10306            "version": 1,
10307            "type": "doc",
10308            "content": [{
10309                "type": "paragraph",
10310                "content": [
10311                    {"type": "text", "text": "See: "},
10312                    {"type": "inlineCard", "attrs": {"url": "https://example.com/path]end/?q=1"}}
10313                ]
10314            }]
10315        }"#;
10316        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10317        let jfm = adf_to_markdown(&adf).unwrap();
10318        assert!(
10319            jfm.contains(r#":card[]{url="https://example.com/path]end/?q=1"}"#),
10320            "expected attr-form for URL with `]`, got: {jfm}"
10321        );
10322        let rt = markdown_to_adf(&jfm).unwrap();
10323        let content = rt.content[0].content.as_ref().unwrap();
10324        assert_eq!(content.len(), 2, "expected 2 inline nodes, got {content:?}");
10325        assert_eq!(content[0].node_type, "text");
10326        assert_eq!(content[0].text.as_deref(), Some("See: "));
10327        assert_eq!(content[1].node_type, "inlineCard");
10328        assert_eq!(
10329            content[1].attrs.as_ref().unwrap()["url"],
10330            "https://example.com/path]end/?q=1"
10331        );
10332    }
10333
10334    #[test]
10335    fn inline_card_url_with_closing_bracket_preserves_local_id() {
10336        // Attr-form `:card[]{url=... localId=...}` must preserve localId too.
10337        let adf_json = r#"{
10338            "version": 1,
10339            "type": "doc",
10340            "content": [{
10341                "type": "paragraph",
10342                "content": [
10343                    {"type": "inlineCard", "attrs": {
10344                        "url": "https://example.com/a]b",
10345                        "localId": "c-77"
10346                    }}
10347                ]
10348            }]
10349        }"#;
10350        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10351        let jfm = adf_to_markdown(&adf).unwrap();
10352        assert!(
10353            jfm.contains(r#"url="https://example.com/a]b""#),
10354            "jfm: {jfm}"
10355        );
10356        assert!(jfm.contains("localId=c-77"), "jfm: {jfm}");
10357        let rt = markdown_to_adf(&jfm).unwrap();
10358        let card = &rt.content[0].content.as_ref().unwrap()[0];
10359        assert_eq!(card.node_type, "inlineCard");
10360        assert_eq!(
10361            card.attrs.as_ref().unwrap()["url"],
10362            "https://example.com/a]b"
10363        );
10364        assert_eq!(card.attrs.as_ref().unwrap()["localId"], "c-77");
10365    }
10366
10367    #[test]
10368    fn block_card_url_with_closing_bracket_round_trip() {
10369        // Same defensive fix applied to the leaf directive `::card`.
10370        let adf_json = r#"{
10371            "version": 1,
10372            "type": "doc",
10373            "content": [
10374                {"type": "blockCard", "attrs": {"url": "https://example.com/path]end"}}
10375            ]
10376        }"#;
10377        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10378        let jfm = adf_to_markdown(&adf).unwrap();
10379        assert!(
10380            jfm.contains(r#"::card[]{url="https://example.com/path]end"}"#),
10381            "expected attr-form for blockCard with `]`, got: {jfm}"
10382        );
10383        let rt = markdown_to_adf(&jfm).unwrap();
10384        assert_eq!(rt.content[0].node_type, "blockCard");
10385        assert_eq!(
10386            rt.content[0].attrs.as_ref().unwrap()["url"],
10387            "https://example.com/path]end"
10388        );
10389    }
10390
10391    #[test]
10392    fn block_card_attr_form_parses_without_renderer() {
10393        // Directly parsing `::card[]{url="..."}` exercises the attr-URL
10394        // fallback in the leaf-directive dispatcher (covers the `url` lookup
10395        // path independently of the ADF→JFM renderer).
10396        let doc = markdown_to_adf(r#"::card[]{url="https://example.com/a"}"#).unwrap();
10397        assert_eq!(doc.content[0].node_type, "blockCard");
10398        assert_eq!(
10399            doc.content[0].attrs.as_ref().unwrap()["url"],
10400            "https://example.com/a"
10401        );
10402    }
10403
10404    #[test]
10405    fn block_card_attr_form_url_overrides_content() {
10406        // When both bracket-content and `url=` attribute are present on
10407        // `::card`, the attribute wins.  Mirrors the inline-directive
10408        // behaviour and keeps hand-edited JFM forgiving.
10409        let doc =
10410            markdown_to_adf(r#"::card[https://old.example.com]{url="https://new.example.com"}"#)
10411                .unwrap();
10412        assert_eq!(doc.content[0].node_type, "blockCard");
10413        assert_eq!(
10414            doc.content[0].attrs.as_ref().unwrap()["url"],
10415            "https://new.example.com"
10416        );
10417    }
10418
10419    #[test]
10420    fn block_card_attr_form_with_layout_and_width() {
10421        // Attr-URL form combined with layout/width attrs — ensures all
10422        // sibling attrs still pass through after the URL lookup.
10423        let doc =
10424            markdown_to_adf(r#"::card[]{url="https://example.com/a]b" layout=wide width=80}"#)
10425                .unwrap();
10426        let attrs = doc.content[0].attrs.as_ref().unwrap();
10427        assert_eq!(attrs["url"], "https://example.com/a]b");
10428        assert_eq!(attrs["layout"], "wide");
10429        assert_eq!(attrs["width"], 80);
10430    }
10431
10432    #[test]
10433    fn inline_card_issue_553_reproducer() {
10434        // Verbatim reproducer from issue #553: an inlineCard in a paragraph
10435        // with preceding text must round-trip as an inlineCard, not degrade to
10436        // a text node with a link mark.
10437        let adf_json = r#"{
10438            "version": 1,
10439            "type": "doc",
10440            "content": [{
10441                "type": "paragraph",
10442                "content": [
10443                    {"type": "text", "text": "See the related page: "},
10444                    {"type": "inlineCard", "attrs": {
10445                        "url": "https://example.atlassian.net/wiki/spaces/ENG/pages/12345678"
10446                    }}
10447                ]
10448            }]
10449        }"#;
10450        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10451        let jfm = adf_to_markdown(&adf).unwrap();
10452        let rt = markdown_to_adf(&jfm).unwrap();
10453        let content = rt.content[0].content.as_ref().unwrap();
10454        assert_eq!(content.len(), 2);
10455        assert_eq!(content[0].node_type, "text");
10456        assert_eq!(content[1].node_type, "inlineCard");
10457        assert_eq!(
10458            content[1].attrs.as_ref().unwrap()["url"],
10459            "https://example.atlassian.net/wiki/spaces/ENG/pages/12345678"
10460        );
10461    }
10462
10463    #[test]
10464    fn inline_card_attr_form_parses_even_without_renderer() {
10465        // Directly parsing `:card[]{url="..."}` should yield an inlineCard.
10466        let doc = markdown_to_adf(r#":card[]{url="https://example.com/a"}"#).unwrap();
10467        let node = &doc.content[0].content.as_ref().unwrap()[0];
10468        assert_eq!(node.node_type, "inlineCard");
10469        assert_eq!(node.attrs.as_ref().unwrap()["url"], "https://example.com/a");
10470    }
10471
10472    #[test]
10473    fn inline_card_attr_form_url_overrides_content() {
10474        // When both bracket-content and `url=` attr are present, attr wins.
10475        // This keeps the parser forgiving of hand-edited JFM where a user
10476        // copied an old bracket form but added attrs.
10477        let doc =
10478            markdown_to_adf(r#":card[https://old.example.com]{url="https://new.example.com"}"#)
10479                .unwrap();
10480        let node = &doc.content[0].content.as_ref().unwrap()[0];
10481        assert_eq!(node.node_type, "inlineCard");
10482        assert_eq!(
10483            node.attrs.as_ref().unwrap()["url"],
10484            "https://new.example.com"
10485        );
10486    }
10487
10488    // ── Issue #553 (updated): mark-wrapped URL must not become inlineCard ──
10489
10490    #[test]
10491    fn url_with_link_and_underline_marks_round_trip() {
10492        // Issue #553 (updated reproducer): a `text` node whose content is a
10493        // URL and that carries both `link` and `underline` marks must round-
10494        // trip as text+marks, not be promoted to an `inlineCard`.
10495        let adf_json = r#"{
10496            "version": 1,
10497            "type": "doc",
10498            "content": [{
10499                "type": "paragraph",
10500                "content": [
10501                    {"type": "text", "text": "See results at: "},
10502                    {"type": "text",
10503                     "text": "https://example.com/projects/abc123/analytics",
10504                     "marks": [
10505                        {"type": "link", "attrs": {"href": "https://example.com/projects/abc123/analytics"}},
10506                        {"type": "underline"}
10507                     ]},
10508                    {"type": "text", "text": " for details."}
10509                ]
10510            }]
10511        }"#;
10512        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10513        let jfm = adf_to_markdown(&adf).unwrap();
10514        let rt = markdown_to_adf(&jfm).unwrap();
10515        let content = rt.content[0].content.as_ref().unwrap();
10516        assert_eq!(
10517            content.len(),
10518            3,
10519            "expected 3 inline nodes, got: {content:?}"
10520        );
10521        assert_eq!(content[0].node_type, "text");
10522        assert_eq!(
10523            content[1].node_type, "text",
10524            "must stay text, not inlineCard"
10525        );
10526        assert_eq!(
10527            content[1].text.as_deref(),
10528            Some("https://example.com/projects/abc123/analytics")
10529        );
10530        let mark_types: Vec<&str> = content[1]
10531            .marks
10532            .as_deref()
10533            .unwrap_or(&[])
10534            .iter()
10535            .map(|m| m.mark_type.as_str())
10536            .collect();
10537        assert_eq!(mark_types, vec!["link", "underline"], "marks lost");
10538        assert_eq!(content[2].node_type, "text");
10539    }
10540
10541    #[test]
10542    fn url_inside_bracketed_span_stays_text() {
10543        // `[URL]{underline}` in JFM means "underline this URL text", not
10544        // "create a smart link that's underlined".  The nested parse_inline
10545        // call must not auto-promote the bare URL to an inlineCard.
10546        let doc = markdown_to_adf("[https://example.com]{underline}").unwrap();
10547        let node = &doc.content[0].content.as_ref().unwrap()[0];
10548        assert_eq!(node.node_type, "text");
10549        assert_eq!(node.text.as_deref(), Some("https://example.com"));
10550        let mark_types: Vec<&str> = node
10551            .marks
10552            .as_deref()
10553            .unwrap_or(&[])
10554            .iter()
10555            .map(|m| m.mark_type.as_str())
10556            .collect();
10557        assert_eq!(mark_types, vec!["underline"]);
10558    }
10559
10560    #[test]
10561    fn url_inside_emphasis_stays_text() {
10562        // Bold, italic, and strike-wrapped URLs should remain as text nodes,
10563        // not get promoted to inlineCards by the nested inline parser.
10564        for (md, mark) in [
10565            ("**https://example.com**", "strong"),
10566            ("*https://example.com*", "em"),
10567            ("~~https://example.com~~", "strike"),
10568        ] {
10569            let doc = markdown_to_adf(md).unwrap();
10570            let node = &doc.content[0].content.as_ref().unwrap()[0];
10571            assert_eq!(node.node_type, "text", "md={md}: must be text");
10572            assert_eq!(node.text.as_deref(), Some("https://example.com"));
10573            let mark_types: Vec<&str> = node
10574                .marks
10575                .as_deref()
10576                .unwrap_or(&[])
10577                .iter()
10578                .map(|m| m.mark_type.as_str())
10579                .collect();
10580            assert_eq!(mark_types, vec![mark], "md={md}: wrong marks");
10581        }
10582    }
10583
10584    #[test]
10585    fn url_inside_span_directive_stays_text() {
10586        // `:span[URL]{color=red}` should not promote the URL to an inlineCard.
10587        let doc = markdown_to_adf(":span[https://example.com]{color=red}").unwrap();
10588        let node = &doc.content[0].content.as_ref().unwrap()[0];
10589        assert_eq!(node.node_type, "text");
10590        assert_eq!(node.text.as_deref(), Some("https://example.com"));
10591        let mark = &node.marks.as_ref().unwrap()[0];
10592        assert_eq!(mark.mark_type, "textColor");
10593    }
10594
10595    #[test]
10596    fn url_as_link_text_with_underline_after_link_mark_order() {
10597        // Reverse mark order — underline appears BEFORE link in the ADF array.
10598        // The JFM form is `[[text](url)]{underline}`; the nested parser must
10599        // still keep the URL as plain text.
10600        let adf_json = r#"{
10601            "version": 1,
10602            "type": "doc",
10603            "content": [{
10604                "type": "paragraph",
10605                "content": [
10606                    {"type": "text",
10607                     "text": "https://example.com",
10608                     "marks": [
10609                        {"type": "underline"},
10610                        {"type": "link", "attrs": {"href": "https://example.com"}}
10611                     ]}
10612                ]
10613            }]
10614        }"#;
10615        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10616        let jfm = adf_to_markdown(&adf).unwrap();
10617        let rt = markdown_to_adf(&jfm).unwrap();
10618        let node = &rt.content[0].content.as_ref().unwrap()[0];
10619        assert_eq!(node.node_type, "text", "must stay text, got: {node:?}");
10620        assert_eq!(node.text.as_deref(), Some("https://example.com"));
10621        let mark_types: Vec<&str> = node
10622            .marks
10623            .as_deref()
10624            .unwrap_or(&[])
10625            .iter()
10626            .map(|m| m.mark_type.as_str())
10627            .collect();
10628        assert_eq!(mark_types, vec!["underline", "link"]);
10629    }
10630
10631    #[test]
10632    fn bare_url_at_top_level_still_becomes_inline_card() {
10633        // Regression guard: the suppression only applies inside mark-wrapping
10634        // constructs.  A bare URL in ordinary paragraph text must still be
10635        // detected and promoted to an inlineCard.
10636        let doc = markdown_to_adf("Visit https://example.com today").unwrap();
10637        let content = doc.content[0].content.as_ref().unwrap();
10638        assert_eq!(content.len(), 3);
10639        assert_eq!(content[0].node_type, "text");
10640        assert_eq!(content[1].node_type, "inlineCard");
10641        assert_eq!(
10642            content[1].attrs.as_ref().unwrap()["url"],
10643            "https://example.com"
10644        );
10645        assert_eq!(content[2].node_type, "text");
10646    }
10647
10648    // ── Block-level attribute marks (Tier 5/6) ───────────────────────
10649
10650    #[test]
10651    fn paragraph_align_center() {
10652        let md = "Centered text.\n{align=center}";
10653        let doc = markdown_to_adf(md).unwrap();
10654        let marks = doc.content[0].marks.as_ref().unwrap();
10655        assert_eq!(marks[0].mark_type, "alignment");
10656        assert_eq!(marks[0].attrs.as_ref().unwrap()["align"], "center");
10657    }
10658
10659    #[test]
10660    fn adf_alignment_to_markdown() {
10661        let mut node = AdfNode::paragraph(vec![AdfNode::text("Centered.")]);
10662        node.marks = Some(vec![AdfMark::alignment("center")]);
10663        let doc = AdfDocument {
10664            version: 1,
10665            doc_type: "doc".to_string(),
10666            content: vec![node],
10667        };
10668        let md = adf_to_markdown(&doc).unwrap();
10669        assert!(md.contains("Centered."));
10670        assert!(md.contains("{align=center}"));
10671    }
10672
10673    #[test]
10674    fn round_trip_alignment() {
10675        let md = "Centered.\n{align=center}\n";
10676        let doc = markdown_to_adf(md).unwrap();
10677        let result = adf_to_markdown(&doc).unwrap();
10678        assert!(result.contains("{align=center}"));
10679    }
10680
10681    #[test]
10682    fn paragraph_indent() {
10683        let md = "Indented.\n{indent=2}";
10684        let doc = markdown_to_adf(md).unwrap();
10685        let marks = doc.content[0].marks.as_ref().unwrap();
10686        assert_eq!(marks[0].mark_type, "indentation");
10687        assert_eq!(marks[0].attrs.as_ref().unwrap()["level"], 2);
10688    }
10689
10690    #[test]
10691    fn code_block_breakout() {
10692        let md = "```python\ndef f(): pass\n```\n{breakout=wide}";
10693        let doc = markdown_to_adf(md).unwrap();
10694        let marks = doc.content[0].marks.as_ref().unwrap();
10695        assert_eq!(marks[0].mark_type, "breakout");
10696        assert_eq!(marks[0].attrs.as_ref().unwrap()["mode"], "wide");
10697        assert!(marks[0].attrs.as_ref().unwrap().get("width").is_none());
10698    }
10699
10700    #[test]
10701    fn code_block_breakout_with_width() {
10702        let md = "```python\ndef f(): pass\n```\n{breakout=wide breakoutWidth=1200}";
10703        let doc = markdown_to_adf(md).unwrap();
10704        let marks = doc.content[0].marks.as_ref().unwrap();
10705        assert_eq!(marks[0].mark_type, "breakout");
10706        assert_eq!(marks[0].attrs.as_ref().unwrap()["mode"], "wide");
10707        assert_eq!(marks[0].attrs.as_ref().unwrap()["width"], 1200);
10708    }
10709
10710    #[test]
10711    fn adf_breakout_to_markdown() {
10712        let mut node = AdfNode::code_block(Some("python"), "pass");
10713        node.marks = Some(vec![AdfMark::breakout("wide", None)]);
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"));
10722    }
10723
10724    #[test]
10725    fn adf_breakout_with_width_to_markdown() {
10726        let mut node = AdfNode::code_block(Some("python"), "pass");
10727        node.marks = Some(vec![AdfMark::breakout("wide", Some(1200))]);
10728        let doc = AdfDocument {
10729            version: 1,
10730            doc_type: "doc".to_string(),
10731            content: vec![node],
10732        };
10733        let md = adf_to_markdown(&doc).unwrap();
10734        assert!(md.contains("breakout=wide"));
10735        assert!(md.contains("breakoutWidth=1200"));
10736    }
10737
10738    #[test]
10739    fn breakout_width_round_trip() {
10740        let adf_json = r#"{"version":1,"type":"doc","content":[{
10741            "type":"codeBlock",
10742            "attrs":{"language":"text"},
10743            "marks":[{"type":"breakout","attrs":{"mode":"wide","width":1200}}],
10744            "content":[{"type":"text","text":"some code"}]
10745        }]}"#;
10746        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10747        let md = adf_to_markdown(&doc).unwrap();
10748        assert!(md.contains("breakout=wide"));
10749        assert!(md.contains("breakoutWidth=1200"));
10750        let round_tripped = markdown_to_adf(&md).unwrap();
10751        let marks = round_tripped.content[0].marks.as_ref().unwrap();
10752        let breakout = marks.iter().find(|m| m.mark_type == "breakout").unwrap();
10753        assert_eq!(breakout.attrs.as_ref().unwrap()["mode"], "wide");
10754        assert_eq!(breakout.attrs.as_ref().unwrap()["width"], 1200);
10755    }
10756
10757    // ── Attribute extensions — media & table (Tier 5) ────────────────
10758
10759    #[test]
10760    fn image_with_layout_attrs() {
10761        let doc = markdown_to_adf("![alt](url){layout=wide width=80}").unwrap();
10762        let node = &doc.content[0];
10763        assert_eq!(node.node_type, "mediaSingle");
10764        let attrs = node.attrs.as_ref().unwrap();
10765        assert_eq!(attrs["layout"], "wide");
10766        assert_eq!(attrs["width"], 80);
10767    }
10768
10769    #[test]
10770    fn adf_image_with_layout_to_markdown() {
10771        let mut node = AdfNode::media_single("url", Some("alt"));
10772        node.attrs.as_mut().unwrap()["layout"] = serde_json::json!("wide");
10773        node.attrs.as_mut().unwrap()["width"] = serde_json::json!(80);
10774        let doc = AdfDocument {
10775            version: 1,
10776            doc_type: "doc".to_string(),
10777            content: vec![node],
10778        };
10779        let md = adf_to_markdown(&doc).unwrap();
10780        assert!(md.contains("![alt](url){layout=wide width=80}"));
10781    }
10782
10783    #[test]
10784    fn table_with_layout_attrs() {
10785        let md = "| H |\n| --- |\n| C |\n{layout=wide numbered}";
10786        let doc = markdown_to_adf(md).unwrap();
10787        let table = &doc.content[0];
10788        assert_eq!(table.node_type, "table");
10789        let attrs = table.attrs.as_ref().unwrap();
10790        assert_eq!(attrs["layout"], "wide");
10791        assert_eq!(attrs["isNumberColumnEnabled"], true);
10792    }
10793
10794    #[test]
10795    fn adf_table_with_attrs_to_markdown() {
10796        let mut table = AdfNode::table(vec![
10797            AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
10798                AdfNode::text("H"),
10799            ])])]),
10800            AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
10801                AdfNode::text("C"),
10802            ])])]),
10803        ]);
10804        table.attrs = Some(serde_json::json!({"layout": "wide", "isNumberColumnEnabled": true}));
10805        let doc = AdfDocument {
10806            version: 1,
10807            doc_type: "doc".to_string(),
10808            content: vec![table],
10809        };
10810        let md = adf_to_markdown(&doc).unwrap();
10811        assert!(md.contains("{layout=wide numbered}"));
10812    }
10813
10814    // ── Attribute extensions — inline marks (Tier 5) ─────────────────
10815
10816    #[test]
10817    fn underline_bracketed_span() {
10818        let doc = markdown_to_adf("This is [underlined text]{underline} here.").unwrap();
10819        let content = doc.content[0].content.as_ref().unwrap();
10820        assert_eq!(content[1].text.as_deref(), Some("underlined text"));
10821        let marks = content[1].marks.as_ref().unwrap();
10822        assert_eq!(marks[0].mark_type, "underline");
10823    }
10824
10825    #[test]
10826    fn adf_underline_to_markdown() {
10827        let doc = AdfDocument {
10828            version: 1,
10829            doc_type: "doc".to_string(),
10830            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10831                "underlined",
10832                vec![AdfMark::underline()],
10833            )])],
10834        };
10835        let md = adf_to_markdown(&doc).unwrap();
10836        assert!(md.contains("[underlined]{underline}"));
10837    }
10838
10839    #[test]
10840    fn round_trip_underline() {
10841        let md = "This is [underlined text]{underline} here.\n";
10842        let doc = markdown_to_adf(md).unwrap();
10843        let result = adf_to_markdown(&doc).unwrap();
10844        assert!(result.contains("[underlined text]{underline}"));
10845    }
10846
10847    #[test]
10848    fn mark_ordering_underline_strong_preserved() {
10849        // Issue #383: mark ordering was non-deterministic
10850        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10851          {"type":"text","text":"bold and underlined","marks":[{"type":"underline"},{"type":"strong"}]}
10852        ]}]}"#;
10853        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10854        let md = adf_to_markdown(&doc).unwrap();
10855        let round_tripped = markdown_to_adf(&md).unwrap();
10856        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
10857        let mark_types: Vec<&str> = node
10858            .marks
10859            .as_ref()
10860            .unwrap()
10861            .iter()
10862            .map(|m| m.mark_type.as_str())
10863            .collect();
10864        assert_eq!(
10865            mark_types,
10866            vec!["underline", "strong"],
10867            "mark order should be preserved, got: {mark_types:?}"
10868        );
10869    }
10870
10871    #[test]
10872    fn mark_ordering_link_strong_preserved() {
10873        // Issue #403: link+strong mark order was swapped on round-trip
10874        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10875          {"type":"text","text":"bold link","marks":[
10876            {"type":"link","attrs":{"href":"https://example.com"}},
10877            {"type":"strong"}
10878          ]}
10879        ]}]}"#;
10880        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10881        let md = adf_to_markdown(&doc).unwrap();
10882        let round_tripped = markdown_to_adf(&md).unwrap();
10883        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
10884        let mark_types: Vec<&str> = node
10885            .marks
10886            .as_ref()
10887            .unwrap()
10888            .iter()
10889            .map(|m| m.mark_type.as_str())
10890            .collect();
10891        assert_eq!(
10892            mark_types,
10893            vec!["link", "strong"],
10894            "mark order should be preserved, got: {mark_types:?}"
10895        );
10896    }
10897
10898    #[test]
10899    fn mark_ordering_link_textcolor_preserved() {
10900        // Issue #403 comment: link+textColor mark order was swapped on round-trip
10901        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10902          {"type":"text","text":"red link","marks":[
10903            {"type":"link","attrs":{"href":"https://example.com"}},
10904            {"type":"textColor","attrs":{"color":"#ff0000"}}
10905          ]}
10906        ]}]}"##;
10907        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10908        let md = adf_to_markdown(&doc).unwrap();
10909        let round_tripped = markdown_to_adf(&md).unwrap();
10910        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
10911        let mark_types: Vec<&str> = node
10912            .marks
10913            .as_ref()
10914            .unwrap()
10915            .iter()
10916            .map(|m| m.mark_type.as_str())
10917            .collect();
10918        assert_eq!(
10919            mark_types,
10920            vec!["link", "textColor"],
10921            "mark order should be preserved, got: {mark_types:?}"
10922        );
10923    }
10924
10925    #[test]
10926    fn mark_ordering_link_em_preserved() {
10927        // Issue #403: link+em mark order should be preserved
10928        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10929          {"type":"text","text":"italic link","marks":[
10930            {"type":"link","attrs":{"href":"https://example.com"}},
10931            {"type":"em"}
10932          ]}
10933        ]}]}"#;
10934        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10935        let md = adf_to_markdown(&doc).unwrap();
10936        let round_tripped = markdown_to_adf(&md).unwrap();
10937        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
10938        let mark_types: Vec<&str> = node
10939            .marks
10940            .as_ref()
10941            .unwrap()
10942            .iter()
10943            .map(|m| m.mark_type.as_str())
10944            .collect();
10945        assert_eq!(
10946            mark_types,
10947            vec!["link", "em"],
10948            "mark order should be preserved, got: {mark_types:?}"
10949        );
10950    }
10951
10952    #[test]
10953    fn mark_ordering_link_strike_preserved() {
10954        // Issue #403: link+strike mark order should be preserved
10955        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10956          {"type":"text","text":"struck link","marks":[
10957            {"type":"link","attrs":{"href":"https://example.com"}},
10958            {"type":"strike"}
10959          ]}
10960        ]}]}"#;
10961        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10962        let md = adf_to_markdown(&doc).unwrap();
10963        let round_tripped = markdown_to_adf(&md).unwrap();
10964        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
10965        let mark_types: Vec<&str> = node
10966            .marks
10967            .as_ref()
10968            .unwrap()
10969            .iter()
10970            .map(|m| m.mark_type.as_str())
10971            .collect();
10972        assert_eq!(
10973            mark_types,
10974            vec!["link", "strike"],
10975            "mark order should be preserved, got: {mark_types:?}"
10976        );
10977    }
10978
10979    #[test]
10980    fn mark_ordering_strong_link_preserved() {
10981        // Issue #403: [strong, link] order must also be preserved (reverse direction)
10982        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10983          {"type":"text","text":"bold link","marks":[
10984            {"type":"strong"},
10985            {"type":"link","attrs":{"href":"https://example.com"}}
10986          ]}
10987        ]}]}"#;
10988        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10989        let md = adf_to_markdown(&doc).unwrap();
10990        let round_tripped = markdown_to_adf(&md).unwrap();
10991        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
10992        let mark_types: Vec<&str> = node
10993            .marks
10994            .as_ref()
10995            .unwrap()
10996            .iter()
10997            .map(|m| m.mark_type.as_str())
10998            .collect();
10999        assert_eq!(
11000            mark_types,
11001            vec!["strong", "link"],
11002            "mark order should be preserved, got: {mark_types:?}"
11003        );
11004    }
11005
11006    #[test]
11007    fn mark_ordering_em_link_preserved() {
11008        // Issue #403: [em, link] order must also be preserved
11009        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11010          {"type":"text","text":"italic link","marks":[
11011            {"type":"em"},
11012            {"type":"link","attrs":{"href":"https://example.com"}}
11013          ]}
11014        ]}]}"#;
11015        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11016        let md = adf_to_markdown(&doc).unwrap();
11017        let round_tripped = markdown_to_adf(&md).unwrap();
11018        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11019        let mark_types: Vec<&str> = node
11020            .marks
11021            .as_ref()
11022            .unwrap()
11023            .iter()
11024            .map(|m| m.mark_type.as_str())
11025            .collect();
11026        assert_eq!(
11027            mark_types,
11028            vec!["em", "link"],
11029            "mark order should be preserved, got: {mark_types:?}"
11030        );
11031    }
11032
11033    #[test]
11034    fn mark_ordering_strike_link_preserved() {
11035        // Issue #403: [strike, link] order must also be preserved
11036        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11037          {"type":"text","text":"struck link","marks":[
11038            {"type":"strike"},
11039            {"type":"link","attrs":{"href":"https://example.com"}}
11040          ]}
11041        ]}]}"#;
11042        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11043        let md = adf_to_markdown(&doc).unwrap();
11044        let round_tripped = markdown_to_adf(&md).unwrap();
11045        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11046        let mark_types: Vec<&str> = node
11047            .marks
11048            .as_ref()
11049            .unwrap()
11050            .iter()
11051            .map(|m| m.mark_type.as_str())
11052            .collect();
11053        assert_eq!(
11054            mark_types,
11055            vec!["strike", "link"],
11056            "mark order should be preserved, got: {mark_types:?}"
11057        );
11058    }
11059
11060    #[test]
11061    fn mark_ordering_underline_link_preserved() {
11062        // Issue #403: [underline, link] order must be preserved
11063        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11064          {"type":"text","text":"click here","marks":[
11065            {"type":"underline"},
11066            {"type":"link","attrs":{"href":"https://example.com"}}
11067          ]}
11068        ]}]}"#;
11069        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11070        let md = adf_to_markdown(&doc).unwrap();
11071        let round_tripped = markdown_to_adf(&md).unwrap();
11072        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11073        let mark_types: Vec<&str> = node
11074            .marks
11075            .as_ref()
11076            .unwrap()
11077            .iter()
11078            .map(|m| m.mark_type.as_str())
11079            .collect();
11080        assert_eq!(
11081            mark_types,
11082            vec!["underline", "link"],
11083            "mark order should be preserved, got: {mark_types:?}"
11084        );
11085    }
11086
11087    #[test]
11088    fn mark_ordering_textcolor_link_preserved() {
11089        // Issue #403: [textColor, link] order must be preserved
11090        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11091          {"type":"text","text":"red link","marks":[
11092            {"type":"textColor","attrs":{"color":"#ff0000"}},
11093            {"type":"link","attrs":{"href":"https://example.com"}}
11094          ]}
11095        ]}]}"##;
11096        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11097        let md = adf_to_markdown(&doc).unwrap();
11098        let round_tripped = markdown_to_adf(&md).unwrap();
11099        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11100        let mark_types: Vec<&str> = node
11101            .marks
11102            .as_ref()
11103            .unwrap()
11104            .iter()
11105            .map(|m| m.mark_type.as_str())
11106            .collect();
11107        assert_eq!(
11108            mark_types,
11109            vec!["textColor", "link"],
11110            "mark order should be preserved, got: {mark_types:?}"
11111        );
11112    }
11113
11114    #[test]
11115    fn mark_ordering_link_underline_preserved() {
11116        // Issue #403: [link, underline] order must be preserved (link wraps bracketed span)
11117        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11118          {"type":"text","text":"click here","marks":[
11119            {"type":"link","attrs":{"href":"https://example.com"}},
11120            {"type":"underline"}
11121          ]}
11122        ]}]}"#;
11123        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11124        let md = adf_to_markdown(&doc).unwrap();
11125        // Link should wrap the underline bracketed span: [[click here]{underline}](url)
11126        assert!(
11127            md.contains("](https://example.com)"),
11128            "should have link: {md}"
11129        );
11130        assert!(md.contains("underline"), "should have underline: {md}");
11131        let round_tripped = markdown_to_adf(&md).unwrap();
11132        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11133        let mark_types: Vec<&str> = node
11134            .marks
11135            .as_ref()
11136            .unwrap()
11137            .iter()
11138            .map(|m| m.mark_type.as_str())
11139            .collect();
11140        assert_eq!(
11141            mark_types,
11142            vec!["link", "underline"],
11143            "mark order should be preserved, got: {mark_types:?}"
11144        );
11145    }
11146
11147    #[test]
11148    fn mark_ordering_underline_strong_link_preserved() {
11149        // Issue #491: [underline, strong, link] reordered to [strong, underline, link]
11150        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11151          {"type":"text","text":"bold underlined link","marks":[
11152            {"type":"underline"},
11153            {"type":"strong"},
11154            {"type":"link","attrs":{"href":"https://example.com/page"}}
11155          ]}
11156        ]}]}"#;
11157        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11158        let md = adf_to_markdown(&doc).unwrap();
11159        let round_tripped = markdown_to_adf(&md).unwrap();
11160        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11161        let mark_types: Vec<&str> = node
11162            .marks
11163            .as_ref()
11164            .unwrap()
11165            .iter()
11166            .map(|m| m.mark_type.as_str())
11167            .collect();
11168        assert_eq!(
11169            mark_types,
11170            vec!["underline", "strong", "link"],
11171            "mark order should be preserved, got: {mark_types:?}"
11172        );
11173    }
11174
11175    #[test]
11176    fn mark_ordering_strong_underline_link_preserved() {
11177        // Issue #491: verify [strong, underline, link] is preserved
11178        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11179          {"type":"text","text":"bold underlined link","marks":[
11180            {"type":"strong"},
11181            {"type":"underline"},
11182            {"type":"link","attrs":{"href":"https://example.com/page"}}
11183          ]}
11184        ]}]}"#;
11185        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11186        let md = adf_to_markdown(&doc).unwrap();
11187        let round_tripped = markdown_to_adf(&md).unwrap();
11188        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11189        let mark_types: Vec<&str> = node
11190            .marks
11191            .as_ref()
11192            .unwrap()
11193            .iter()
11194            .map(|m| m.mark_type.as_str())
11195            .collect();
11196        assert_eq!(
11197            mark_types,
11198            vec!["strong", "underline", "link"],
11199            "mark order should be preserved, got: {mark_types:?}"
11200        );
11201    }
11202
11203    #[test]
11204    fn mark_ordering_underline_em_link_preserved() {
11205        // Issue #491: verify [underline, em, link] is preserved
11206        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11207          {"type":"text","text":"italic underlined link","marks":[
11208            {"type":"underline"},
11209            {"type":"em"},
11210            {"type":"link","attrs":{"href":"https://example.com/page"}}
11211          ]}
11212        ]}]}"#;
11213        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11214        let md = adf_to_markdown(&doc).unwrap();
11215        let round_tripped = markdown_to_adf(&md).unwrap();
11216        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11217        let mark_types: Vec<&str> = node
11218            .marks
11219            .as_ref()
11220            .unwrap()
11221            .iter()
11222            .map(|m| m.mark_type.as_str())
11223            .collect();
11224        assert_eq!(
11225            mark_types,
11226            vec!["underline", "em", "link"],
11227            "mark order should be preserved, got: {mark_types:?}"
11228        );
11229    }
11230
11231    #[test]
11232    fn mark_ordering_underline_strike_link_preserved() {
11233        // Issue #491: verify [underline, strike, link] is preserved
11234        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11235          {"type":"text","text":"struck underlined link","marks":[
11236            {"type":"underline"},
11237            {"type":"strike"},
11238            {"type":"link","attrs":{"href":"https://example.com/page"}}
11239          ]}
11240        ]}]}"#;
11241        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11242        let md = adf_to_markdown(&doc).unwrap();
11243        let round_tripped = markdown_to_adf(&md).unwrap();
11244        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11245        let mark_types: Vec<&str> = node
11246            .marks
11247            .as_ref()
11248            .unwrap()
11249            .iter()
11250            .map(|m| m.mark_type.as_str())
11251            .collect();
11252        assert_eq!(
11253            mark_types,
11254            vec!["underline", "strike", "link"],
11255            "mark order should be preserved, got: {mark_types:?}"
11256        );
11257    }
11258
11259    #[test]
11260    fn mark_ordering_underline_strong_em_link_preserved() {
11261        // Issue #491: verify four-mark combo [underline, strong, em, link] is preserved
11262        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11263          {"type":"text","text":"all the marks","marks":[
11264            {"type":"underline"},
11265            {"type":"strong"},
11266            {"type":"em"},
11267            {"type":"link","attrs":{"href":"https://example.com/page"}}
11268          ]}
11269        ]}]}"#;
11270        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11271        let md = adf_to_markdown(&doc).unwrap();
11272        let round_tripped = markdown_to_adf(&md).unwrap();
11273        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11274        let mark_types: Vec<&str> = node
11275            .marks
11276            .as_ref()
11277            .unwrap()
11278            .iter()
11279            .map(|m| m.mark_type.as_str())
11280            .collect();
11281        assert_eq!(
11282            mark_types,
11283            vec!["underline", "strong", "em", "link"],
11284            "mark order should be preserved, got: {mark_types:?}"
11285        );
11286    }
11287
11288    #[test]
11289    fn em_strong_round_trip() {
11290        // Issue #401: em mark dropped when combined with strong
11291        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11292          {"type":"text","text":"bold and italic","marks":[{"type":"strong"},{"type":"em"}]}
11293        ]}]}"#;
11294        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11295        let md = adf_to_markdown(&doc).unwrap();
11296        assert_eq!(md.trim(), "***bold and italic***");
11297        let round_tripped = markdown_to_adf(&md).unwrap();
11298        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11299        assert_eq!(node.text.as_deref(), Some("bold and italic"));
11300        let mark_types: Vec<&str> = node
11301            .marks
11302            .as_ref()
11303            .unwrap()
11304            .iter()
11305            .map(|m| m.mark_type.as_str())
11306            .collect();
11307        assert_eq!(
11308            mark_types,
11309            vec!["strong", "em"],
11310            "both strong and em marks should be preserved, got: {mark_types:?}"
11311        );
11312    }
11313
11314    #[test]
11315    fn em_strong_round_trip_em_first() {
11316        // Issue #549: [em, strong] mark order must be preserved on round-trip.
11317        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11318          {"type":"text","text":"italic and bold","marks":[{"type":"em"},{"type":"strong"}]}
11319        ]}]}"#;
11320        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11321        let md = adf_to_markdown(&doc).unwrap();
11322        let round_tripped = markdown_to_adf(&md).unwrap();
11323        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11324        assert_eq!(node.text.as_deref(), Some("italic and bold"));
11325        let mark_types: Vec<&str> = node
11326            .marks
11327            .as_ref()
11328            .unwrap()
11329            .iter()
11330            .map(|m| m.mark_type.as_str())
11331            .collect();
11332        assert_eq!(
11333            mark_types,
11334            vec!["em", "strong"],
11335            "mark order [em, strong] should be preserved, got: {mark_types:?}"
11336        );
11337    }
11338
11339    /// Round-trips an inline text node with the given marks through ADF → JFM → ADF
11340    /// and asserts the resulting mark types match `expected`.
11341    fn assert_mark_order_round_trip(marks: Vec<AdfMark>, expected: &[&str]) {
11342        let doc = AdfDocument {
11343            version: 1,
11344            doc_type: "doc".to_string(),
11345            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11346                "text", marks,
11347            )])],
11348        };
11349        let md = adf_to_markdown(&doc).unwrap();
11350        let round_tripped = markdown_to_adf(&md).unwrap();
11351        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11352        let mark_types: Vec<&str> = node
11353            .marks
11354            .as_ref()
11355            .expect("round-tripped node should have marks")
11356            .iter()
11357            .map(|m| m.mark_type.as_str())
11358            .collect();
11359        assert_eq!(
11360            mark_types, expected,
11361            "marks should round-trip in order via {md:?}"
11362        );
11363    }
11364
11365    #[test]
11366    fn round_trip_em_strong_mark_order() {
11367        // Issue #549: em + strong in either order must round-trip.
11368        assert_mark_order_round_trip(vec![AdfMark::em(), AdfMark::strong()], &["em", "strong"]);
11369        assert_mark_order_round_trip(vec![AdfMark::strong(), AdfMark::em()], &["strong", "em"]);
11370    }
11371
11372    #[test]
11373    fn round_trip_strong_underline_mark_order() {
11374        // Issue #549: strong + underline in either order must round-trip.
11375        assert_mark_order_round_trip(
11376            vec![AdfMark::strong(), AdfMark::underline()],
11377            &["strong", "underline"],
11378        );
11379        assert_mark_order_round_trip(
11380            vec![AdfMark::underline(), AdfMark::strong()],
11381            &["underline", "strong"],
11382        );
11383    }
11384
11385    #[test]
11386    fn round_trip_em_underline_mark_order() {
11387        assert_mark_order_round_trip(
11388            vec![AdfMark::em(), AdfMark::underline()],
11389            &["em", "underline"],
11390        );
11391        assert_mark_order_round_trip(
11392            vec![AdfMark::underline(), AdfMark::em()],
11393            &["underline", "em"],
11394        );
11395    }
11396
11397    #[test]
11398    fn round_trip_strike_strong_em_permutations() {
11399        // Each permutation of {strike, strong, em} must round-trip the mark order
11400        // exactly, because the Atlassian ADF spec does not define a canonical mark
11401        // ordering and we preserve whatever ordering Jira delivered.
11402        assert_mark_order_round_trip(
11403            vec![AdfMark::strike(), AdfMark::strong(), AdfMark::em()],
11404            &["strike", "strong", "em"],
11405        );
11406        assert_mark_order_round_trip(
11407            vec![AdfMark::strike(), AdfMark::em(), AdfMark::strong()],
11408            &["strike", "em", "strong"],
11409        );
11410        assert_mark_order_round_trip(
11411            vec![AdfMark::strong(), AdfMark::strike(), AdfMark::em()],
11412            &["strong", "strike", "em"],
11413        );
11414        assert_mark_order_round_trip(
11415            vec![AdfMark::strong(), AdfMark::em(), AdfMark::strike()],
11416            &["strong", "em", "strike"],
11417        );
11418        assert_mark_order_round_trip(
11419            vec![AdfMark::em(), AdfMark::strike(), AdfMark::strong()],
11420            &["em", "strike", "strong"],
11421        );
11422        assert_mark_order_round_trip(
11423            vec![AdfMark::em(), AdfMark::strong(), AdfMark::strike()],
11424            &["em", "strong", "strike"],
11425        );
11426    }
11427
11428    #[test]
11429    fn round_trip_underline_nested_with_strong_em() {
11430        // Underline may sit outside, between, or inside strong/em — each position
11431        // must round-trip.
11432        assert_mark_order_round_trip(
11433            vec![AdfMark::underline(), AdfMark::strong(), AdfMark::em()],
11434            &["underline", "strong", "em"],
11435        );
11436        assert_mark_order_round_trip(
11437            vec![AdfMark::strong(), AdfMark::underline(), AdfMark::em()],
11438            &["strong", "underline", "em"],
11439        );
11440        assert_mark_order_round_trip(
11441            vec![AdfMark::strong(), AdfMark::em(), AdfMark::underline()],
11442            &["strong", "em", "underline"],
11443        );
11444    }
11445
11446    #[test]
11447    fn round_trip_span_attr_order_preserved() {
11448        // Issue #549: the `:span` directive always parses color/bg/subsup
11449        // attrs in a fixed order, so non-canonical orderings must be emitted
11450        // as nested :span wrappers rather than a single merged wrapper.
11451        assert_mark_order_round_trip(
11452            vec![
11453                AdfMark::background_color("#ffff00"),
11454                AdfMark::text_color("#ff0000"),
11455            ],
11456            &["backgroundColor", "textColor"],
11457        );
11458        assert_mark_order_round_trip(
11459            vec![AdfMark::subsup("sub"), AdfMark::text_color("#ff0000")],
11460            &["subsup", "textColor"],
11461        );
11462        assert_mark_order_round_trip(
11463            vec![
11464                AdfMark::text_color("#ff0000"),
11465                AdfMark::background_color("#ffff00"),
11466            ],
11467            &["textColor", "backgroundColor"],
11468        );
11469    }
11470
11471    #[test]
11472    fn round_trip_annotation_before_underline() {
11473        // Issue #549: the bracketed-span parser reads `underline` before any
11474        // annotation-ids, so `[annotation, underline]` must be emitted as
11475        // nested wrappers rather than one merged `[text]{underline annotation-id=X}`.
11476        assert_mark_order_round_trip(
11477            vec![
11478                AdfMark::annotation("ann-1", "inlineComment"),
11479                AdfMark::underline(),
11480            ],
11481            &["annotation", "underline"],
11482        );
11483        assert_mark_order_round_trip(
11484            vec![
11485                AdfMark::annotation("ann-1", "inlineComment"),
11486                AdfMark::underline(),
11487                AdfMark::annotation("ann-2", "inlineComment"),
11488            ],
11489            &["annotation", "underline", "annotation"],
11490        );
11491    }
11492
11493    #[test]
11494    fn round_trip_em_content_with_underscores() {
11495        // When em renders as `_..._` (to disambiguate from strong), any literal
11496        // underscores in the text must be escaped so they don't close the
11497        // emphasis span early.  Text like "foo_bar_baz" with [em, strong] must
11498        // survive round-trip with the underscores intact.
11499        let doc = AdfDocument {
11500            version: 1,
11501            doc_type: "doc".to_string(),
11502            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11503                "foo _bar_ baz",
11504                vec![AdfMark::em(), AdfMark::strong()],
11505            )])],
11506        };
11507        let md = adf_to_markdown(&doc).unwrap();
11508        let round_tripped = markdown_to_adf(&md).unwrap();
11509        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11510        assert_eq!(node.text.as_deref(), Some("foo _bar_ baz"));
11511        let mark_types: Vec<&str> = node
11512            .marks
11513            .as_ref()
11514            .unwrap()
11515            .iter()
11516            .map(|m| m.mark_type.as_str())
11517            .collect();
11518        assert_eq!(mark_types, vec!["em", "strong"]);
11519    }
11520
11521    #[test]
11522    fn round_trip_link_nested_with_formatting_marks() {
11523        // Link may sit at any position in the marks array relative to em,
11524        // strong, strike, and underline — each position must round-trip.
11525        assert_mark_order_round_trip(
11526            vec![
11527                AdfMark::link("https://example.com"),
11528                AdfMark::strong(),
11529                AdfMark::em(),
11530            ],
11531            &["link", "strong", "em"],
11532        );
11533        assert_mark_order_round_trip(
11534            vec![
11535                AdfMark::em(),
11536                AdfMark::strong(),
11537                AdfMark::link("https://example.com"),
11538            ],
11539            &["em", "strong", "link"],
11540        );
11541        assert_mark_order_round_trip(
11542            vec![
11543                AdfMark::underline(),
11544                AdfMark::link("https://example.com"),
11545                AdfMark::strong(),
11546            ],
11547            &["underline", "link", "strong"],
11548        );
11549    }
11550
11551    /// Builds an `AdfMark` with the given type and no attrs, bypassing the
11552    /// usual constructors so we can exercise the defensive branches in the
11553    /// render helpers (the constructors always populate `attrs`).
11554    fn bare_mark(mark_type: &str) -> AdfMark {
11555        AdfMark {
11556            mark_type: mark_type.to_string(),
11557            attrs: None,
11558        }
11559    }
11560
11561    #[test]
11562    fn collect_span_attr_handles_missing_attrs() {
11563        // `textColor`/`backgroundColor`/`subsup` marks without the expected
11564        // `color`/`type` attr must not emit a fragment (the `if let` falls
11565        // through without pushing).  This exercises the inner-None branches
11566        // that the typed-constructor tests otherwise skip.
11567        let mut attrs = Vec::new();
11568        collect_span_attr(&bare_mark("textColor"), &mut attrs);
11569        collect_span_attr(&bare_mark("backgroundColor"), &mut attrs);
11570        collect_span_attr(&bare_mark("subsup"), &mut attrs);
11571        collect_span_attr(&bare_mark("link"), &mut attrs);
11572        assert!(attrs.is_empty(), "got: {attrs:?}");
11573    }
11574
11575    #[test]
11576    fn collect_bracketed_attr_handles_missing_attrs() {
11577        // An annotation mark with no attrs map at all must silently produce
11578        // no fragments — this covers the outer `if let Some(ref a)` None arm.
11579        let mut attrs = Vec::new();
11580        collect_bracketed_attr(&bare_mark("annotation"), &mut attrs);
11581        collect_bracketed_attr(&bare_mark("strong"), &mut attrs);
11582        assert!(attrs.is_empty(), "got: {attrs:?}");
11583    }
11584
11585    #[test]
11586    fn collect_bracketed_attr_handles_annotation_without_id() {
11587        // An annotation mark with attrs present but missing `id` and
11588        // `annotationType` keys still emits nothing — exercises the inner
11589        // None branches of each `if let` in the annotation arm.
11590        let mark = AdfMark {
11591            mark_type: "annotation".to_string(),
11592            attrs: Some(serde_json::json!({})),
11593        };
11594        let mut attrs = Vec::new();
11595        collect_bracketed_attr(&mark, &mut attrs);
11596        assert!(attrs.is_empty(), "got: {attrs:?}");
11597    }
11598
11599    #[test]
11600    fn span_attr_order_rejects_unknown_types() {
11601        // `span_attr_order` must classify unknown mark types as the sentinel
11602        // value, and `span_run_is_canonical` must reject a run that contains
11603        // any such unknown type.
11604        assert_eq!(span_attr_order("textColor"), 0);
11605        assert_eq!(span_attr_order("backgroundColor"), 1);
11606        assert_eq!(span_attr_order("subsup"), 2);
11607        assert_eq!(span_attr_order("strong"), u8::MAX);
11608        assert!(!span_run_is_canonical(&[bare_mark("strong")]));
11609    }
11610
11611    #[test]
11612    fn bracketed_run_rejects_unknown_types() {
11613        // `bracketed_run_is_canonical` only accepts `underline` and
11614        // `annotation`; any other mark type in the run short-circuits to
11615        // `false` so the caller emits nested wrappers.
11616        assert!(bracketed_run_is_canonical(&[
11617            AdfMark::underline(),
11618            AdfMark::annotation("x", "inlineComment")
11619        ]));
11620        assert!(!bracketed_run_is_canonical(&[
11621            AdfMark::annotation("x", "inlineComment"),
11622            AdfMark::underline()
11623        ]));
11624        assert!(!bracketed_run_is_canonical(&[bare_mark("strong")]));
11625    }
11626
11627    #[test]
11628    fn render_marked_text_ignores_unknown_mark_types() {
11629        // Unknown mark types fall through `render_marked_text`'s `_ =>`
11630        // arm and are dropped; the rendered JFM must still produce the
11631        // underlying text (and round-trip back to an unmarked text node).
11632        let doc = AdfDocument {
11633            version: 1,
11634            doc_type: "doc".to_string(),
11635            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11636                "hello",
11637                vec![bare_mark("futureMark"), AdfMark::strong()],
11638            )])],
11639        };
11640        let md = adf_to_markdown(&doc).unwrap();
11641        assert_eq!(md.trim(), "**hello**");
11642        let rt = markdown_to_adf(&md).unwrap();
11643        let node = &rt.content[0].content.as_ref().unwrap()[0];
11644        assert_eq!(node.text.as_deref(), Some("hello"));
11645        let mark_types: Vec<&str> = node
11646            .marks
11647            .as_ref()
11648            .unwrap()
11649            .iter()
11650            .map(|m| m.mark_type.as_str())
11651            .collect();
11652        assert_eq!(mark_types, vec!["strong"]);
11653    }
11654
11655    #[test]
11656    fn triple_asterisk_parse_to_adf() {
11657        // Issue #401: ***text*** should parse as text with strong+em marks
11658        let md = "***bold and italic***\n";
11659        let doc = markdown_to_adf(md).unwrap();
11660        let node = &doc.content[0].content.as_ref().unwrap()[0];
11661        assert_eq!(node.text.as_deref(), Some("bold and italic"));
11662        let mark_types: Vec<&str> = node
11663            .marks
11664            .as_ref()
11665            .unwrap()
11666            .iter()
11667            .map(|m| m.mark_type.as_str())
11668            .collect();
11669        assert!(
11670            mark_types.contains(&"strong") && mark_types.contains(&"em"),
11671            "***text*** should produce both strong and em marks, got: {mark_types:?}"
11672        );
11673    }
11674
11675    #[test]
11676    fn triple_asterisk_with_surrounding_text() {
11677        // Issue #401: surrounding text should not be affected
11678        let md = "before ***bold italic*** after\n";
11679        let doc = markdown_to_adf(md).unwrap();
11680        let nodes = doc.content[0].content.as_ref().unwrap();
11681        // Should have: "before " (plain), "bold italic" (strong+em), " after" (plain)
11682        assert!(
11683            nodes.len() >= 3,
11684            "expected at least 3 nodes, got {}",
11685            nodes.len()
11686        );
11687        assert_eq!(nodes[0].text.as_deref(), Some("before "));
11688        assert_eq!(nodes[1].text.as_deref(), Some("bold italic"));
11689        let mark_types: Vec<&str> = nodes[1]
11690            .marks
11691            .as_ref()
11692            .unwrap()
11693            .iter()
11694            .map(|m| m.mark_type.as_str())
11695            .collect();
11696        assert!(
11697            mark_types.contains(&"strong") && mark_types.contains(&"em"),
11698            "middle node should have strong+em, got: {mark_types:?}"
11699        );
11700        assert_eq!(nodes[2].text.as_deref(), Some(" after"));
11701    }
11702
11703    #[test]
11704    fn annotation_mark_round_trip() {
11705        // Issue #364: annotation marks were silently dropped
11706        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11707          {"type":"text","text":"highlighted text","marks":[
11708            {"type":"annotation","attrs":{"id":"abc123","annotationType":"inlineComment"}}
11709          ]}
11710        ]}]}"#;
11711        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11712
11713        let md = adf_to_markdown(&doc).unwrap();
11714        assert!(
11715            md.contains("annotation-id="),
11716            "JFM should contain annotation-id, got: {md}"
11717        );
11718
11719        let round_tripped = markdown_to_adf(&md).unwrap();
11720        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11721        assert_eq!(text_node.text.as_deref(), Some("highlighted text"));
11722        let marks = text_node.marks.as_ref().expect("should have marks");
11723        let ann = marks
11724            .iter()
11725            .find(|m| m.mark_type == "annotation")
11726            .expect("should have annotation mark");
11727        let attrs = ann.attrs.as_ref().unwrap();
11728        assert_eq!(attrs["id"], "abc123");
11729        assert_eq!(attrs["annotationType"], "inlineComment");
11730    }
11731
11732    #[test]
11733    fn annotation_mark_with_bold() {
11734        // Annotation + bold should both survive round-trip
11735        let doc = AdfDocument {
11736            version: 1,
11737            doc_type: "doc".to_string(),
11738            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11739                "bold comment",
11740                vec![
11741                    AdfMark::strong(),
11742                    AdfMark::annotation("def456", "inlineComment"),
11743                ],
11744            )])],
11745        };
11746        let md = adf_to_markdown(&doc).unwrap();
11747        let round_tripped = markdown_to_adf(&md).unwrap();
11748        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11749        let marks = text_node.marks.as_ref().expect("should have marks");
11750        assert!(
11751            marks.iter().any(|m| m.mark_type == "strong"),
11752            "should have strong mark"
11753        );
11754        assert!(
11755            marks.iter().any(|m| m.mark_type == "annotation"),
11756            "should have annotation mark"
11757        );
11758    }
11759
11760    #[test]
11761    fn annotation_and_link_marks_both_preserved() {
11762        // Issue #390: text with both annotation and link marks loses link on round-trip
11763        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11764          {"type":"text","text":"HANGUL-8","marks":[
11765            {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"5ca7425e-34cd-48d3-b4eb-9873ac8b20e0"}},
11766            {"type":"link","attrs":{"href":"https://zd.atlassian.net/browse/HANG-8"}}
11767          ]}
11768        ]}]}"#;
11769        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11770        let md = adf_to_markdown(&doc).unwrap();
11771        // Should contain both annotation attrs and link syntax
11772        assert!(
11773            md.contains("annotation-id="),
11774            "JFM should contain annotation-id, got: {md}"
11775        );
11776        assert!(
11777            md.contains("](https://"),
11778            "JFM should contain link href, got: {md}"
11779        );
11780        let round_tripped = markdown_to_adf(&md).unwrap();
11781        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11782        let marks = text_node.marks.as_ref().expect("should have marks");
11783        assert!(
11784            marks.iter().any(|m| m.mark_type == "annotation"),
11785            "should have annotation mark, got: {:?}",
11786            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
11787        );
11788        assert!(
11789            marks.iter().any(|m| m.mark_type == "link"),
11790            "should have link mark, got: {:?}",
11791            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
11792        );
11793    }
11794
11795    #[test]
11796    fn annotation_and_code_marks_both_preserved() {
11797        // Issue #508: annotation mark dropped when combined with code mark
11798        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11799          {"type":"text","text":"some text with "},
11800          {"type":"text","text":"annotated code","marks":[
11801            {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"aabbccdd-1234-5678-abcd-000000000001"}},
11802            {"type":"code"}
11803          ]},
11804          {"type":"text","text":" remaining text"}
11805        ]}]}"#;
11806        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11807        let md = adf_to_markdown(&doc).unwrap();
11808        assert!(
11809            md.contains("annotation-id="),
11810            "JFM should contain annotation-id, got: {md}"
11811        );
11812        assert!(
11813            md.contains('`'),
11814            "JFM should contain backticks for code, got: {md}"
11815        );
11816
11817        let round_tripped = markdown_to_adf(&md).unwrap();
11818        let nodes = round_tripped.content[0].content.as_ref().unwrap();
11819        // Find the text node with "annotated code"
11820        let code_node = nodes
11821            .iter()
11822            .find(|n| n.text.as_deref() == Some("annotated code"))
11823            .expect("should have 'annotated code' text node");
11824        let marks = code_node.marks.as_ref().expect("should have marks");
11825        assert!(
11826            marks.iter().any(|m| m.mark_type == "annotation"),
11827            "should have annotation mark, got: {:?}",
11828            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
11829        );
11830        assert!(
11831            marks.iter().any(|m| m.mark_type == "code"),
11832            "should have code mark, got: {:?}",
11833            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
11834        );
11835        let ann = marks.iter().find(|m| m.mark_type == "annotation").unwrap();
11836        let attrs = ann.attrs.as_ref().unwrap();
11837        assert_eq!(attrs["id"], "aabbccdd-1234-5678-abcd-000000000001");
11838        assert_eq!(attrs["annotationType"], "inlineComment");
11839    }
11840
11841    #[test]
11842    fn annotation_and_code_and_link_marks_all_preserved() {
11843        // annotation + code + link should all survive round-trip
11844        let doc = AdfDocument {
11845            version: 1,
11846            doc_type: "doc".to_string(),
11847            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11848                "linked code",
11849                vec![
11850                    AdfMark::annotation("ann-001", "inlineComment"),
11851                    AdfMark::code(),
11852                    AdfMark::link("https://example.com"),
11853                ],
11854            )])],
11855        };
11856        let md = adf_to_markdown(&doc).unwrap();
11857        assert!(
11858            md.contains("annotation-id="),
11859            "JFM should contain annotation-id, got: {md}"
11860        );
11861        assert!(md.contains('`'), "JFM should contain backticks, got: {md}");
11862        assert!(
11863            md.contains("](https://example.com)"),
11864            "JFM should contain link, got: {md}"
11865        );
11866
11867        let round_tripped = markdown_to_adf(&md).unwrap();
11868        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11869        let marks = text_node.marks.as_ref().expect("should have marks");
11870        assert!(
11871            marks.iter().any(|m| m.mark_type == "annotation"),
11872            "should have annotation mark, got: {:?}",
11873            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
11874        );
11875        assert!(
11876            marks.iter().any(|m| m.mark_type == "code"),
11877            "should have code mark, got: {:?}",
11878            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
11879        );
11880        assert!(
11881            marks.iter().any(|m| m.mark_type == "link"),
11882            "should have link mark, got: {:?}",
11883            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
11884        );
11885    }
11886
11887    #[test]
11888    fn multiple_annotations_and_code_mark_preserved() {
11889        // Multiple annotation marks on a code node should all survive
11890        let doc = AdfDocument {
11891            version: 1,
11892            doc_type: "doc".to_string(),
11893            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11894                "doubly annotated",
11895                vec![
11896                    AdfMark::annotation("ann-aaa", "inlineComment"),
11897                    AdfMark::annotation("ann-bbb", "inlineComment"),
11898                    AdfMark::code(),
11899                ],
11900            )])],
11901        };
11902        let md = adf_to_markdown(&doc).unwrap();
11903        assert!(
11904            md.contains("ann-aaa"),
11905            "JFM should contain first annotation id, got: {md}"
11906        );
11907        assert!(
11908            md.contains("ann-bbb"),
11909            "JFM should contain second annotation id, got: {md}"
11910        );
11911
11912        let round_tripped = markdown_to_adf(&md).unwrap();
11913        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11914        let marks = text_node.marks.as_ref().expect("should have marks");
11915        let ann_marks: Vec<_> = marks
11916            .iter()
11917            .filter(|m| m.mark_type == "annotation")
11918            .collect();
11919        assert_eq!(
11920            ann_marks.len(),
11921            2,
11922            "should have 2 annotation marks, got: {}",
11923            ann_marks.len()
11924        );
11925        assert!(
11926            marks.iter().any(|m| m.mark_type == "code"),
11927            "should have code mark"
11928        );
11929    }
11930
11931    #[test]
11932    fn underline_and_link_marks_both_preserved() {
11933        // Underline + link should also coexist
11934        let doc = AdfDocument {
11935            version: 1,
11936            doc_type: "doc".to_string(),
11937            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11938                "click here",
11939                vec![AdfMark::underline(), AdfMark::link("https://example.com")],
11940            )])],
11941        };
11942        let md = adf_to_markdown(&doc).unwrap();
11943        assert!(md.contains("underline"), "should have underline attr: {md}");
11944        assert!(
11945            md.contains("](https://example.com)"),
11946            "should have link: {md}"
11947        );
11948        let round_tripped = markdown_to_adf(&md).unwrap();
11949        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11950        let marks = text_node.marks.as_ref().expect("should have marks");
11951        assert!(marks.iter().any(|m| m.mark_type == "underline"));
11952        assert!(marks.iter().any(|m| m.mark_type == "link"));
11953    }
11954
11955    #[test]
11956    fn annotation_link_and_bold_all_preserved() {
11957        // All three marks should coexist
11958        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11959          {"type":"text","text":"important","marks":[
11960            {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"abc"}},
11961            {"type":"link","attrs":{"href":"https://example.com"}},
11962            {"type":"strong"}
11963          ]}
11964        ]}]}"#;
11965        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11966        let md = adf_to_markdown(&doc).unwrap();
11967        let round_tripped = markdown_to_adf(&md).unwrap();
11968        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11969        let marks = text_node.marks.as_ref().expect("should have marks");
11970        assert!(
11971            marks.iter().any(|m| m.mark_type == "annotation"),
11972            "should have annotation"
11973        );
11974        assert!(
11975            marks.iter().any(|m| m.mark_type == "link"),
11976            "should have link"
11977        );
11978        assert!(
11979            marks.iter().any(|m| m.mark_type == "strong"),
11980            "should have strong"
11981        );
11982    }
11983
11984    #[test]
11985    fn multiple_annotation_marks_round_trip() {
11986        // Issue #439: multiple annotation marks on same text node — all but last dropped
11987        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11988          {"type":"text","text":"some annotated text","marks":[
11989            {"type":"annotation","attrs":{"id":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","annotationType":"inlineComment"}},
11990            {"type":"annotation","attrs":{"id":"ffffffff-1111-2222-3333-444444444444","annotationType":"inlineComment"}}
11991          ]}
11992        ]}]}"#;
11993        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11994
11995        let md = adf_to_markdown(&doc).unwrap();
11996        assert!(
11997            md.contains("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"),
11998            "JFM should contain first annotation id, got: {md}"
11999        );
12000        assert!(
12001            md.contains("ffffffff-1111-2222-3333-444444444444"),
12002            "JFM should contain second annotation id, got: {md}"
12003        );
12004
12005        let round_tripped = markdown_to_adf(&md).unwrap();
12006        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12007        assert_eq!(text_node.text.as_deref(), Some("some annotated text"));
12008        let marks = text_node.marks.as_ref().expect("should have marks");
12009        let annotations: Vec<_> = marks
12010            .iter()
12011            .filter(|m| m.mark_type == "annotation")
12012            .collect();
12013        assert_eq!(
12014            annotations.len(),
12015            2,
12016            "should have 2 annotation marks, got: {annotations:?}"
12017        );
12018        let ids: Vec<_> = annotations
12019            .iter()
12020            .map(|a| a.attrs.as_ref().unwrap()["id"].as_str().unwrap())
12021            .collect();
12022        assert!(ids.contains(&"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"));
12023        assert!(ids.contains(&"ffffffff-1111-2222-3333-444444444444"));
12024    }
12025
12026    #[test]
12027    fn three_annotation_marks_round_trip() {
12028        // Verify three overlapping annotations all survive
12029        let doc = AdfDocument {
12030            version: 1,
12031            doc_type: "doc".to_string(),
12032            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12033                "triple annotated",
12034                vec![
12035                    AdfMark::annotation("id-1", "inlineComment"),
12036                    AdfMark::annotation("id-2", "inlineComment"),
12037                    AdfMark::annotation("id-3", "inlineComment"),
12038                ],
12039            )])],
12040        };
12041        let md = adf_to_markdown(&doc).unwrap();
12042        let round_tripped = markdown_to_adf(&md).unwrap();
12043        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12044        let marks = text_node.marks.as_ref().expect("should have marks");
12045        let annotations: Vec<_> = marks
12046            .iter()
12047            .filter(|m| m.mark_type == "annotation")
12048            .collect();
12049        assert_eq!(
12050            annotations.len(),
12051            3,
12052            "should have 3 annotation marks, got: {annotations:?}"
12053        );
12054    }
12055
12056    #[test]
12057    fn multiple_annotations_with_bold_round_trip() {
12058        // Multiple annotations + bold should all survive
12059        let doc = AdfDocument {
12060            version: 1,
12061            doc_type: "doc".to_string(),
12062            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12063                "bold double annotated",
12064                vec![
12065                    AdfMark::strong(),
12066                    AdfMark::annotation("ann-a", "inlineComment"),
12067                    AdfMark::annotation("ann-b", "inlineComment"),
12068                ],
12069            )])],
12070        };
12071        let md = adf_to_markdown(&doc).unwrap();
12072        let round_tripped = markdown_to_adf(&md).unwrap();
12073        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12074        let marks = text_node.marks.as_ref().expect("should have marks");
12075        assert!(
12076            marks.iter().any(|m| m.mark_type == "strong"),
12077            "should have strong mark"
12078        );
12079        let annotations: Vec<_> = marks
12080            .iter()
12081            .filter(|m| m.mark_type == "annotation")
12082            .collect();
12083        assert_eq!(
12084            annotations.len(),
12085            2,
12086            "should have 2 annotation marks, got: {annotations:?}"
12087        );
12088    }
12089
12090    #[test]
12091    fn multiple_annotations_with_link_round_trip() {
12092        // Multiple annotations + link should all survive
12093        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12094          {"type":"text","text":"linked text","marks":[
12095            {"type":"annotation","attrs":{"id":"ann-x","annotationType":"inlineComment"}},
12096            {"type":"annotation","attrs":{"id":"ann-y","annotationType":"inlineComment"}},
12097            {"type":"link","attrs":{"href":"https://example.com"}}
12098          ]}
12099        ]}]}"#;
12100        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12101        let md = adf_to_markdown(&doc).unwrap();
12102        let round_tripped = markdown_to_adf(&md).unwrap();
12103        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12104        let marks = text_node.marks.as_ref().expect("should have marks");
12105        assert!(
12106            marks.iter().any(|m| m.mark_type == "link"),
12107            "should have link mark"
12108        );
12109        let annotations: Vec<_> = marks
12110            .iter()
12111            .filter(|m| m.mark_type == "annotation")
12112            .collect();
12113        assert_eq!(
12114            annotations.len(),
12115            2,
12116            "should have 2 annotation marks, got: {annotations:?}"
12117        );
12118    }
12119
12120    // ── Issue #471: annotation marks on non-text inline nodes ─────────
12121
12122    #[test]
12123    fn annotation_on_emoji_round_trip() {
12124        // Issue #471: annotation mark on emoji node should survive round-trip
12125        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12126          {"type":"emoji","attrs":{"id":"1f4dd","shortName":":memo:","text":"📝"},"marks":[
12127            {"type":"annotation","attrs":{"id":"ccddee11-2233-4455-aabb-ccddee112233","annotationType":"inlineComment"}}
12128          ]},
12129          {"type":"text","text":" annotated text","marks":[
12130            {"type":"annotation","attrs":{"id":"ccddee11-2233-4455-aabb-ccddee112233","annotationType":"inlineComment"}}
12131          ]}
12132        ]}]}"#;
12133        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12134        let md = adf_to_markdown(&doc).unwrap();
12135        assert!(
12136            md.contains("annotation-id="),
12137            "JFM should contain annotation-id for emoji, got: {md}"
12138        );
12139
12140        let round_tripped = markdown_to_adf(&md).unwrap();
12141        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12142
12143        // Emoji node should retain annotation mark
12144        let emoji_node = nodes.iter().find(|n| n.node_type == "emoji").unwrap();
12145        let emoji_marks = emoji_node.marks.as_ref().expect("emoji should have marks");
12146        assert!(
12147            emoji_marks.iter().any(|m| m.mark_type == "annotation"),
12148            "emoji should have annotation mark, got: {emoji_marks:?}"
12149        );
12150        let ann = emoji_marks
12151            .iter()
12152            .find(|m| m.mark_type == "annotation")
12153            .unwrap();
12154        assert_eq!(
12155            ann.attrs.as_ref().unwrap()["id"],
12156            "ccddee11-2233-4455-aabb-ccddee112233"
12157        );
12158
12159        // Text node should also retain annotation mark
12160        let text_node = nodes.iter().find(|n| n.node_type == "text").unwrap();
12161        let text_marks = text_node.marks.as_ref().expect("text should have marks");
12162        assert!(
12163            text_marks.iter().any(|m| m.mark_type == "annotation"),
12164            "text should have annotation mark"
12165        );
12166    }
12167
12168    #[test]
12169    fn annotation_on_status_round_trip() {
12170        let mut status = AdfNode::status("In Progress", "blue");
12171        status.marks = Some(vec![AdfMark::annotation("ann-status-1", "inlineComment")]);
12172
12173        let doc = AdfDocument {
12174            version: 1,
12175            doc_type: "doc".to_string(),
12176            content: vec![AdfNode::paragraph(vec![status])],
12177        };
12178        let md = adf_to_markdown(&doc).unwrap();
12179        assert!(
12180            md.contains("annotation-id="),
12181            "JFM should contain annotation-id for status, got: {md}"
12182        );
12183
12184        let round_tripped = markdown_to_adf(&md).unwrap();
12185        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12186        let status_node = nodes.iter().find(|n| n.node_type == "status").unwrap();
12187        let marks = status_node
12188            .marks
12189            .as_ref()
12190            .expect("status should have marks");
12191        assert!(
12192            marks.iter().any(|m| m.mark_type == "annotation"),
12193            "status should have annotation mark, got: {marks:?}"
12194        );
12195    }
12196
12197    #[test]
12198    fn annotation_on_date_round_trip() {
12199        let mut date = AdfNode::date("1704067200000");
12200        date.marks = Some(vec![AdfMark::annotation("ann-date-1", "inlineComment")]);
12201
12202        let doc = AdfDocument {
12203            version: 1,
12204            doc_type: "doc".to_string(),
12205            content: vec![AdfNode::paragraph(vec![date])],
12206        };
12207        let md = adf_to_markdown(&doc).unwrap();
12208        assert!(
12209            md.contains("annotation-id="),
12210            "JFM should contain annotation-id for date, got: {md}"
12211        );
12212
12213        let round_tripped = markdown_to_adf(&md).unwrap();
12214        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12215        let date_node = nodes.iter().find(|n| n.node_type == "date").unwrap();
12216        let marks = date_node.marks.as_ref().expect("date should have marks");
12217        assert!(
12218            marks.iter().any(|m| m.mark_type == "annotation"),
12219            "date should have annotation mark, got: {marks:?}"
12220        );
12221    }
12222
12223    #[test]
12224    fn annotation_on_mention_round_trip() {
12225        let mut mention = AdfNode::mention("user-123", "@Alice");
12226        mention.marks = Some(vec![AdfMark::annotation("ann-mention-1", "inlineComment")]);
12227
12228        let doc = AdfDocument {
12229            version: 1,
12230            doc_type: "doc".to_string(),
12231            content: vec![AdfNode::paragraph(vec![mention])],
12232        };
12233        let md = adf_to_markdown(&doc).unwrap();
12234        assert!(
12235            md.contains("annotation-id="),
12236            "JFM should contain annotation-id for mention, got: {md}"
12237        );
12238
12239        let round_tripped = markdown_to_adf(&md).unwrap();
12240        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12241        let mention_node = nodes.iter().find(|n| n.node_type == "mention").unwrap();
12242        let marks = mention_node
12243            .marks
12244            .as_ref()
12245            .expect("mention should have marks");
12246        assert!(
12247            marks.iter().any(|m| m.mark_type == "annotation"),
12248            "mention should have annotation mark, got: {marks:?}"
12249        );
12250    }
12251
12252    #[test]
12253    fn annotation_on_inline_card_round_trip() {
12254        let mut card = AdfNode::inline_card("https://example.com");
12255        card.marks = Some(vec![AdfMark::annotation("ann-card-1", "inlineComment")]);
12256
12257        let doc = AdfDocument {
12258            version: 1,
12259            doc_type: "doc".to_string(),
12260            content: vec![AdfNode::paragraph(vec![card])],
12261        };
12262        let md = adf_to_markdown(&doc).unwrap();
12263        assert!(
12264            md.contains("annotation-id="),
12265            "JFM should contain annotation-id for inlineCard, got: {md}"
12266        );
12267
12268        let round_tripped = markdown_to_adf(&md).unwrap();
12269        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12270        let card_node = nodes.iter().find(|n| n.node_type == "inlineCard").unwrap();
12271        let marks = card_node
12272            .marks
12273            .as_ref()
12274            .expect("inlineCard should have marks");
12275        assert!(
12276            marks.iter().any(|m| m.mark_type == "annotation"),
12277            "inlineCard should have annotation mark, got: {marks:?}"
12278        );
12279    }
12280
12281    #[test]
12282    fn annotation_on_placeholder_round_trip() {
12283        let mut placeholder = AdfNode::placeholder("Enter text here");
12284        placeholder.marks = Some(vec![AdfMark::annotation("ann-ph-1", "inlineComment")]);
12285
12286        let doc = AdfDocument {
12287            version: 1,
12288            doc_type: "doc".to_string(),
12289            content: vec![AdfNode::paragraph(vec![placeholder])],
12290        };
12291        let md = adf_to_markdown(&doc).unwrap();
12292        assert!(
12293            md.contains("annotation-id="),
12294            "JFM should contain annotation-id for placeholder, got: {md}"
12295        );
12296
12297        let round_tripped = markdown_to_adf(&md).unwrap();
12298        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12299        let ph_node = nodes.iter().find(|n| n.node_type == "placeholder").unwrap();
12300        let marks = ph_node
12301            .marks
12302            .as_ref()
12303            .expect("placeholder should have marks");
12304        assert!(
12305            marks.iter().any(|m| m.mark_type == "annotation"),
12306            "placeholder should have annotation mark, got: {marks:?}"
12307        );
12308    }
12309
12310    #[test]
12311    fn multiple_annotations_on_emoji_round_trip() {
12312        // Multiple annotation marks on a single emoji node
12313        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12314          {"type":"emoji","attrs":{"shortName":":fire:","text":"🔥"},"marks":[
12315            {"type":"annotation","attrs":{"id":"ann-1","annotationType":"inlineComment"}},
12316            {"type":"annotation","attrs":{"id":"ann-2","annotationType":"inlineComment"}}
12317          ]}
12318        ]}]}"#;
12319        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12320        let md = adf_to_markdown(&doc).unwrap();
12321
12322        let round_tripped = markdown_to_adf(&md).unwrap();
12323        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12324        let emoji_node = nodes.iter().find(|n| n.node_type == "emoji").unwrap();
12325        let marks = emoji_node.marks.as_ref().expect("emoji should have marks");
12326        let annotations: Vec<_> = marks
12327            .iter()
12328            .filter(|m| m.mark_type == "annotation")
12329            .collect();
12330        assert_eq!(
12331            annotations.len(),
12332            2,
12333            "emoji should have 2 annotation marks, got: {annotations:?}"
12334        );
12335    }
12336
12337    #[test]
12338    fn emoji_without_annotation_unchanged() {
12339        // Ensure emoji nodes without annotation marks are not affected
12340        let doc = AdfDocument {
12341            version: 1,
12342            doc_type: "doc".to_string(),
12343            content: vec![AdfNode::paragraph(vec![AdfNode::emoji(":fire:")])],
12344        };
12345        let md = adf_to_markdown(&doc).unwrap();
12346        // Should NOT have bracketed span wrapping
12347        assert!(
12348            !md.contains('['),
12349            "emoji without annotation should not be wrapped in brackets, got: {md}"
12350        );
12351        assert!(md.contains(":fire:"));
12352    }
12353
12354    // ── Inline directive tests (Tier 4) ───────────────────────────────
12355
12356    #[test]
12357    fn status_directive() {
12358        let doc = markdown_to_adf("The ticket is :status[In Progress]{color=blue}.").unwrap();
12359        let content = doc.content[0].content.as_ref().unwrap();
12360        assert_eq!(content[1].node_type, "status");
12361        assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "In Progress");
12362        assert_eq!(content[1].attrs.as_ref().unwrap()["color"], "blue");
12363    }
12364
12365    #[test]
12366    fn adf_status_to_markdown() {
12367        let doc = AdfDocument {
12368            version: 1,
12369            doc_type: "doc".to_string(),
12370            content: vec![AdfNode::paragraph(vec![AdfNode::status("Done", "green")])],
12371        };
12372        let md = adf_to_markdown(&doc).unwrap();
12373        assert!(md.contains(":status[Done]{color=green}"));
12374    }
12375
12376    #[test]
12377    fn round_trip_status() {
12378        let md = "The ticket is :status[In Progress]{color=blue}.\n";
12379        let doc = markdown_to_adf(md).unwrap();
12380        let result = adf_to_markdown(&doc).unwrap();
12381        assert!(result.contains(":status[In Progress]{color=blue}"));
12382    }
12383
12384    #[test]
12385    fn status_with_style_and_localid_roundtrips() {
12386        let adf = AdfDocument {
12387            version: 1,
12388            doc_type: "doc".to_string(),
12389            content: vec![AdfNode::paragraph(vec![{
12390                let mut node = AdfNode::status("open", "green");
12391                node.attrs.as_mut().unwrap()["style"] =
12392                    serde_json::Value::String("bold".to_string());
12393                node.attrs.as_mut().unwrap()["localId"] =
12394                    serde_json::Value::String("d2205ca5-84b9-4950-a730-bfe550fc146b".to_string());
12395                node
12396            }])],
12397        };
12398
12399        let md = adf_to_markdown(&adf).unwrap();
12400        assert!(
12401            md.contains("style=bold"),
12402            "Markdown should contain style attr: {md}"
12403        );
12404        assert!(
12405            md.contains("localId=d2205ca5"),
12406            "Markdown should contain localId attr: {md}"
12407        );
12408
12409        let rt = markdown_to_adf(&md).unwrap();
12410        let status = &rt.content[0].content.as_ref().unwrap()[0];
12411        let attrs = status.attrs.as_ref().unwrap();
12412        assert_eq!(attrs["text"], "open");
12413        assert_eq!(attrs["color"], "green");
12414        assert_eq!(attrs["style"], "bold");
12415        assert_eq!(
12416            attrs["localId"], "d2205ca5-84b9-4950-a730-bfe550fc146b",
12417            "localId should be preserved, got: {}",
12418            attrs["localId"]
12419        );
12420    }
12421
12422    #[test]
12423    fn status_without_style_still_works() {
12424        let md = ":status[Done]{color=green}\n";
12425        let doc = markdown_to_adf(md).unwrap();
12426        let status = &doc.content[0].content.as_ref().unwrap()[0];
12427        let attrs = status.attrs.as_ref().unwrap();
12428        assert_eq!(attrs["text"], "Done");
12429        assert_eq!(attrs["color"], "green");
12430        // No style attr — should not be present
12431        assert!(
12432            attrs.get("style").is_none() || attrs["style"].is_null(),
12433            "style should not be set when not provided"
12434        );
12435    }
12436
12437    #[test]
12438    fn strip_local_ids_removes_localid_from_status() {
12439        let adf = AdfDocument {
12440            version: 1,
12441            doc_type: "doc".to_string(),
12442            content: vec![AdfNode::paragraph(vec![{
12443                let mut node = AdfNode::status("open", "green");
12444                node.attrs.as_mut().unwrap()["localId"] =
12445                    serde_json::Value::String("real-uuid-here".to_string());
12446                node
12447            }])],
12448        };
12449        let opts = RenderOptions {
12450            strip_local_ids: true,
12451        };
12452        let md = adf_to_markdown_with_options(&adf, &opts).unwrap();
12453        assert!(
12454            !md.contains("localId"),
12455            "localId should be stripped, got: {md}"
12456        );
12457        assert!(md.contains("color=green"), "color should be preserved");
12458    }
12459
12460    #[test]
12461    fn strip_local_ids_removes_localid_from_table() {
12462        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"}]}]}]}]}]}"#;
12463        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12464        let opts = RenderOptions {
12465            strip_local_ids: true,
12466        };
12467        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12468        assert!(
12469            !md.contains("localId"),
12470            "localId should be stripped from table, got: {md}"
12471        );
12472        assert!(md.contains("layout=default"), "layout should be preserved");
12473    }
12474
12475    #[test]
12476    fn default_options_preserve_localid() {
12477        let adf = AdfDocument {
12478            version: 1,
12479            doc_type: "doc".to_string(),
12480            content: vec![AdfNode::paragraph(vec![{
12481                let mut node = AdfNode::status("open", "green");
12482                node.attrs.as_mut().unwrap()["localId"] =
12483                    serde_json::Value::String("real-uuid-here".to_string());
12484                node
12485            }])],
12486        };
12487        let md = adf_to_markdown(&adf).unwrap();
12488        assert!(
12489            md.contains("localId=real-uuid-here"),
12490            "Default should preserve localId, got: {md}"
12491        );
12492    }
12493
12494    #[test]
12495    fn mention_localid_roundtrip() {
12496        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"mention","attrs":{"id":"user123","text":"@Alice","localId":"m-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=m-001"),
12501            "mention should have localId in md: {md}"
12502        );
12503        let rt = markdown_to_adf(&md).unwrap();
12504        let mention = &rt.content[0].content.as_ref().unwrap()[0];
12505        assert_eq!(mention.attrs.as_ref().unwrap()["localId"], "m-001");
12506    }
12507
12508    #[test]
12509    fn date_localid_roundtrip() {
12510        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-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=d-001"),
12515            "date should have localId in md: {md}"
12516        );
12517        let rt = markdown_to_adf(&md).unwrap();
12518        let date = &rt.content[0].content.as_ref().unwrap()[0];
12519        assert_eq!(date.attrs.as_ref().unwrap()["localId"], "d-001");
12520    }
12521
12522    #[test]
12523    fn emoji_localid_roundtrip() {
12524        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"emoji","attrs":{"shortName":":smile:","localId":"e-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=e-001"),
12529            "emoji should have localId in md: {md}"
12530        );
12531        let rt = markdown_to_adf(&md).unwrap();
12532        let emoji = &rt.content[0].content.as_ref().unwrap()[0];
12533        assert_eq!(emoji.attrs.as_ref().unwrap()["localId"], "e-001");
12534    }
12535
12536    #[test]
12537    fn inline_card_localid_roundtrip() {
12538        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"inlineCard","attrs":{"url":"https://example.com","localId":"c-001"}}]}]}"#;
12539        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12540        let md = adf_to_markdown(&doc).unwrap();
12541        assert!(
12542            md.contains("localId=c-001"),
12543            "inlineCard should have localId in md: {md}"
12544        );
12545        let rt = markdown_to_adf(&md).unwrap();
12546        let card = &rt.content[0].content.as_ref().unwrap()[0];
12547        assert_eq!(card.attrs.as_ref().unwrap()["localId"], "c-001");
12548    }
12549
12550    #[test]
12551    fn strip_local_ids_removes_from_mention() {
12552        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"mention","attrs":{"id":"user123","text":"@Alice","localId":"m-001"}}]}]}"#;
12553        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12554        let opts = RenderOptions {
12555            strip_local_ids: true,
12556        };
12557        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12558        assert!(
12559            !md.contains("localId"),
12560            "localId should be stripped from mention: {md}"
12561        );
12562        assert!(md.contains("id=user123"), "other attrs should be preserved");
12563    }
12564
12565    #[test]
12566    fn strip_local_ids_removes_from_date() {
12567        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
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 date: {md}"
12576        );
12577    }
12578
12579    #[test]
12580    fn strip_local_ids_removes_from_block_attrs() {
12581        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"p-001"},"content":[{"type":"text","text":"hello"}]}]}"#;
12582        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12583        let opts = RenderOptions {
12584            strip_local_ids: true,
12585        };
12586        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12587        assert!(
12588            !md.contains("localId"),
12589            "localId should be stripped from block attrs: {md}"
12590        );
12591    }
12592
12593    #[test]
12594    fn table_cell_localid_roundtrip() {
12595        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"}]}]}]}]}]}"#;
12596        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12597        let md = adf_to_markdown(&doc).unwrap();
12598        assert!(
12599            md.contains("localId=tc-001"),
12600            "tableCell should have localId in md: {md}"
12601        );
12602        let rt = markdown_to_adf(&md).unwrap();
12603        let cell = &rt.content[0].content.as_ref().unwrap()[0]
12604            .content
12605            .as_ref()
12606            .unwrap()[0];
12607        assert_eq!(
12608            cell.attrs.as_ref().unwrap()["localId"],
12609            "tc-001",
12610            "tableCell localId should round-trip"
12611        );
12612    }
12613
12614    #[test]
12615    fn table_cell_border_mark_roundtrip() {
12616        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"}]}]}]}]}]}"##;
12617        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12618        let md = adf_to_markdown(&doc).unwrap();
12619        assert!(
12620            md.contains("border-color=#ff000033"),
12621            "tableCell should have border-color in md: {md}"
12622        );
12623        assert!(
12624            md.contains("border-size=2"),
12625            "tableCell should have border-size in md: {md}"
12626        );
12627        let rt = markdown_to_adf(&md).unwrap();
12628        let cell = &rt.content[0].content.as_ref().unwrap()[0]
12629            .content
12630            .as_ref()
12631            .unwrap()[0];
12632        let marks = cell.marks.as_ref().expect("tableCell should have marks");
12633        assert_eq!(marks.len(), 1);
12634        assert_eq!(marks[0].mark_type, "border");
12635        let attrs = marks[0].attrs.as_ref().unwrap();
12636        assert_eq!(attrs["color"], "#ff000033");
12637        assert_eq!(attrs["size"], 2);
12638    }
12639
12640    #[test]
12641    fn table_header_border_mark_roundtrip() {
12642        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"}]}]}]}]}]}"##;
12643        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12644        let md = adf_to_markdown(&doc).unwrap();
12645        assert!(md.contains("border-color=#0000ff"), "md: {md}");
12646        assert!(md.contains("border-size=3"), "md: {md}");
12647        let rt = markdown_to_adf(&md).unwrap();
12648        let cell = &rt.content[0].content.as_ref().unwrap()[0]
12649            .content
12650            .as_ref()
12651            .unwrap()[0];
12652        assert_eq!(cell.node_type, "tableHeader");
12653        let marks = cell.marks.as_ref().expect("tableHeader should have marks");
12654        assert_eq!(marks[0].mark_type, "border");
12655        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#0000ff");
12656        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
12657    }
12658
12659    #[test]
12660    fn table_cell_border_mark_with_attrs_roundtrip() {
12661        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"}]}]}]}]}]}"##;
12662        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12663        let md = adf_to_markdown(&doc).unwrap();
12664        assert!(md.contains("bg=#e6fcff"), "md: {md}");
12665        assert!(md.contains("colspan=2"), "md: {md}");
12666        assert!(md.contains("border-color=#ff000033"), "md: {md}");
12667        let rt = markdown_to_adf(&md).unwrap();
12668        let cell = &rt.content[0].content.as_ref().unwrap()[0]
12669            .content
12670            .as_ref()
12671            .unwrap()[0];
12672        assert_eq!(cell.attrs.as_ref().unwrap()["background"], "#e6fcff");
12673        assert_eq!(cell.attrs.as_ref().unwrap()["colspan"], 2);
12674        let marks = cell.marks.as_ref().expect("should have marks");
12675        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff000033");
12676    }
12677
12678    #[test]
12679    fn table_cell_no_border_mark_unchanged() {
12680        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"}]}]}]}]}]}"#;
12681        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12682        let md = adf_to_markdown(&doc).unwrap();
12683        assert!(
12684            !md.contains("border-color"),
12685            "no border attrs expected: {md}"
12686        );
12687        let rt = markdown_to_adf(&md).unwrap();
12688        let cell = &rt.content[0].content.as_ref().unwrap()[0]
12689            .content
12690            .as_ref()
12691            .unwrap()[0];
12692        assert!(cell.marks.is_none(), "no marks expected on plain cell");
12693    }
12694
12695    #[test]
12696    fn table_cell_border_size_only_defaults_color() {
12697        // border-size without border-color should still produce a border mark
12698        // with the default color
12699        let md = "::::table\n:::tr\n:::td{border-size=3}\ncell\n:::\n:::\n::::\n";
12700        let doc = markdown_to_adf(md).unwrap();
12701        let cell = &doc.content[0].content.as_ref().unwrap()[0]
12702            .content
12703            .as_ref()
12704            .unwrap()[0];
12705        let marks = cell.marks.as_ref().expect("should have border mark");
12706        assert_eq!(marks[0].mark_type, "border");
12707        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#000000");
12708        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
12709    }
12710
12711    #[test]
12712    fn table_cell_border_color_only_defaults_size() {
12713        // border-color without border-size should default size to 1
12714        let md = "::::table\n:::tr\n:::td{border-color=#ff0000}\ncell\n:::\n:::\n::::\n";
12715        let doc = markdown_to_adf(md).unwrap();
12716        let cell = &doc.content[0].content.as_ref().unwrap()[0]
12717            .content
12718            .as_ref()
12719            .unwrap()[0];
12720        let marks = cell.marks.as_ref().expect("should have border mark");
12721        assert_eq!(marks[0].mark_type, "border");
12722        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff0000");
12723        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 1);
12724    }
12725
12726    #[test]
12727    fn media_file_border_mark_roundtrip() {
12728        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}}]}]}]}"##;
12729        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12730        let md = adf_to_markdown(&doc).unwrap();
12731        assert!(
12732            md.contains("border-color=#091e4224"),
12733            "media should have border-color in md: {md}"
12734        );
12735        assert!(
12736            md.contains("border-size=2"),
12737            "media should have border-size in md: {md}"
12738        );
12739        let rt = markdown_to_adf(&md).unwrap();
12740        let media_single = &rt.content[0];
12741        let media = &media_single.content.as_ref().unwrap()[0];
12742        assert_eq!(media.node_type, "media");
12743        let marks = media.marks.as_ref().expect("media should have marks");
12744        assert_eq!(marks.len(), 1);
12745        assert_eq!(marks[0].mark_type, "border");
12746        let attrs = marks[0].attrs.as_ref().unwrap();
12747        assert_eq!(attrs["color"], "#091e4224");
12748        assert_eq!(attrs["size"], 2);
12749    }
12750
12751    #[test]
12752    fn media_external_border_mark_roundtrip() {
12753        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}}]}]}]}"##;
12754        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12755        let md = adf_to_markdown(&doc).unwrap();
12756        assert!(
12757            md.contains("border-color=#ff0000"),
12758            "external media should have border-color in md: {md}"
12759        );
12760        assert!(
12761            md.contains("border-size=3"),
12762            "external media should have border-size in md: {md}"
12763        );
12764        let rt = markdown_to_adf(&md).unwrap();
12765        let media = &rt.content[0].content.as_ref().unwrap()[0];
12766        let marks = media.marks.as_ref().expect("media should have marks");
12767        assert_eq!(marks[0].mark_type, "border");
12768        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff0000");
12769        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
12770    }
12771
12772    #[test]
12773    fn media_file_no_border_mark_unchanged() {
12774        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}}]}]}"#;
12775        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12776        let md = adf_to_markdown(&doc).unwrap();
12777        assert!(
12778            !md.contains("border-color"),
12779            "no border attrs expected: {md}"
12780        );
12781        let rt = markdown_to_adf(&md).unwrap();
12782        let media = &rt.content[0].content.as_ref().unwrap()[0];
12783        assert!(media.marks.is_none(), "no marks expected on plain media");
12784    }
12785
12786    #[test]
12787    fn media_border_size_only_defaults_color() {
12788        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}}]}]}]}"#;
12789        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12790        let md = adf_to_markdown(&doc).unwrap();
12791        assert!(md.contains("border-size=4"), "md: {md}");
12792        let rt = markdown_to_adf(&md).unwrap();
12793        let media = &rt.content[0].content.as_ref().unwrap()[0];
12794        let marks = media.marks.as_ref().expect("should have border mark");
12795        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#000000");
12796        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 4);
12797    }
12798
12799    #[test]
12800    fn media_border_color_only_defaults_size() {
12801        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"}}]}]}]}"##;
12802        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12803        let md = adf_to_markdown(&doc).unwrap();
12804        assert!(md.contains("border-color=#00ff00"), "md: {md}");
12805        let rt = markdown_to_adf(&md).unwrap();
12806        let media = &rt.content[0].content.as_ref().unwrap()[0];
12807        let marks = media.marks.as_ref().expect("should have border mark");
12808        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#00ff00");
12809        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 1);
12810    }
12811
12812    #[test]
12813    fn media_border_with_other_attrs_roundtrip() {
12814        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}}]}]}]}"##;
12815        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12816        let md = adf_to_markdown(&doc).unwrap();
12817        assert!(md.contains("layout=wide"), "md: {md}");
12818        assert!(md.contains("mediaWidth=600"), "md: {md}");
12819        assert!(md.contains("border-color=#091e4224"), "md: {md}");
12820        assert!(md.contains("border-size=2"), "md: {md}");
12821        let rt = markdown_to_adf(&md).unwrap();
12822        let ms = &rt.content[0];
12823        assert_eq!(ms.attrs.as_ref().unwrap()["layout"], "wide");
12824        let media = &ms.content.as_ref().unwrap()[0];
12825        let marks = media.marks.as_ref().expect("should have marks");
12826        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#091e4224");
12827        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 2);
12828    }
12829
12830    #[test]
12831    fn table_row_localid_roundtrip() {
12832        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"}]}]}]}]}]}"#;
12833        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12834        let md = adf_to_markdown(&doc).unwrap();
12835        assert!(
12836            md.contains("localId=tr-001"),
12837            "tableRow should have localId in md: {md}"
12838        );
12839        let rt = markdown_to_adf(&md).unwrap();
12840        let row = &rt.content[0].content.as_ref().unwrap()[0];
12841        assert_eq!(
12842            row.attrs.as_ref().unwrap()["localId"],
12843            "tr-001",
12844            "tableRow localId should round-trip"
12845        );
12846    }
12847
12848    #[test]
12849    fn list_item_localid_roundtrip() {
12850        // listItem localId is emitted as trailing inline attrs and parsed back
12851        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"}]}]}]}]}"#;
12852        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12853        let md = adf_to_markdown(&doc).unwrap();
12854        assert!(
12855            md.contains("localId=li-001"),
12856            "listItem should have localId in md: {md}"
12857        );
12858        // Verify localId is on the listItem, NOT promoted to bulletList
12859        let rt = markdown_to_adf(&md).unwrap();
12860        let list = &rt.content[0];
12861        assert!(
12862            list.attrs.is_none() || list.attrs.as_ref().unwrap().get("localId").is_none(),
12863            "bulletList should NOT have localId: {:?}",
12864            list.attrs
12865        );
12866        let item = &list.content.as_ref().unwrap()[0];
12867        assert_eq!(
12868            item.attrs.as_ref().unwrap()["localId"],
12869            "li-001",
12870            "listItem should have localId=li-001"
12871        );
12872    }
12873
12874    #[test]
12875    fn list_item_localid_not_promoted_to_parent() {
12876        // Verify localId stays on listItem and doesn't leak to parent list
12877        let md = "- item {localId=li-002}\n";
12878        let doc = markdown_to_adf(md).unwrap();
12879        let list = &doc.content[0];
12880        assert!(
12881            list.attrs.is_none(),
12882            "bulletList should have no attrs: {:?}",
12883            list.attrs
12884        );
12885        let item = &list.content.as_ref().unwrap()[0];
12886        assert_eq!(item.attrs.as_ref().unwrap()["localId"], "li-002");
12887    }
12888
12889    #[test]
12890    fn ordered_list_item_localid_roundtrip() {
12891        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"}]}]}]}]}"#;
12892        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12893        let md = adf_to_markdown(&doc).unwrap();
12894        assert!(md.contains("localId=oli-001"), "md: {md}");
12895        let rt = markdown_to_adf(&md).unwrap();
12896        let item = &rt.content[0].content.as_ref().unwrap()[0];
12897        assert_eq!(item.attrs.as_ref().unwrap()["localId"], "oli-001");
12898    }
12899
12900    #[test]
12901    fn task_item_localid_roundtrip() {
12902        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"}]}]}]}]}"#;
12903        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12904        let md = adf_to_markdown(&doc).unwrap();
12905        assert!(md.contains("localId=ti-001"), "md: {md}");
12906        let rt = markdown_to_adf(&md).unwrap();
12907        let item = &rt.content[0].content.as_ref().unwrap()[0];
12908        assert_eq!(item.attrs.as_ref().unwrap()["localId"], "ti-001");
12909    }
12910
12911    /// Issue #447: taskList with empty-string localId and taskItems with
12912    /// short numeric localIds must survive a full round-trip.
12913    #[test]
12914    fn task_list_short_localid_roundtrip() {
12915        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"}]}]}]}"#;
12916        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12917        let md = adf_to_markdown(&doc).unwrap();
12918        // Both taskItem localIds should appear in the markdown
12919        assert!(md.contains("localId=42"), "localId=42 missing: {md}");
12920        assert!(md.contains("localId=99"), "localId=99 missing: {md}");
12921        // Empty-string localId should NOT appear as {localId=}
12922        assert!(
12923            !md.contains("localId=}"),
12924            "empty localId should not be emitted: {md}"
12925        );
12926        let rt = markdown_to_adf(&md).unwrap();
12927        let task_list = &rt.content[0];
12928        assert_eq!(task_list.node_type, "taskList");
12929        // No spurious extra nodes from {localId=}
12930        assert_eq!(rt.content.len(), 1, "should be exactly one top-level node");
12931        let items = task_list.content.as_ref().unwrap();
12932        assert_eq!(items.len(), 2);
12933        // First taskItem: localId=42, state=TODO, no content
12934        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
12935        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
12936        assert!(
12937            items[0].content.is_none(),
12938            "empty taskItem should have no content: {:?}",
12939            items[0].content
12940        );
12941        // Second taskItem: localId=99, state=DONE, content with text
12942        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "99");
12943        assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
12944        let content = items[1].content.as_ref().unwrap();
12945        assert_eq!(content.len(), 1);
12946        assert_eq!(content[0].text.as_deref(), Some("done task"));
12947    }
12948
12949    /// Issue #507: numeric localId on taskItem with hardBreak must survive
12950    /// round-trip — the {localId=…} suffix lands on the continuation line
12951    /// and must still be extracted by the parser.
12952    #[test]
12953    fn task_item_numeric_localid_with_hardbreak_roundtrip() {
12954        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!!)"}]}]}]}]}"#;
12955        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12956        let md = adf_to_markdown(&doc).unwrap();
12957        // localId must appear in the markdown output
12958        assert!(md.contains("localId=42"), "localId=42 missing: {md}");
12959        // Round-trip back to ADF
12960        let rt = markdown_to_adf(&md).unwrap();
12961        assert_eq!(rt.content.len(), 1, "exactly one top-level node");
12962        let task_list = &rt.content[0];
12963        assert_eq!(task_list.node_type, "taskList");
12964        let items = task_list.content.as_ref().unwrap();
12965        assert_eq!(items.len(), 1);
12966        // localId preserved
12967        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
12968        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DONE");
12969        // Content structure preserved: paragraph with link + hardBreak + text
12970        let para = &items[0].content.as_ref().unwrap()[0];
12971        assert_eq!(para.node_type, "paragraph");
12972        let inlines = para.content.as_ref().unwrap();
12973        assert_eq!(inlines[0].node_type, "text");
12974        assert_eq!(
12975            inlines[0].text.as_deref(),
12976            Some("Engineering Onboarding Link")
12977        );
12978        assert_eq!(inlines[1].node_type, "hardBreak");
12979        assert_eq!(inlines[2].node_type, "text");
12980        assert_eq!(
12981            inlines[2].text.as_deref(),
12982            Some("(This has links to all the various useful tools!!)")
12983        );
12984        // The {localId=…} must not appear as literal text in the ADF output
12985        let rt_json = serde_json::to_string(&rt).unwrap();
12986        assert!(
12987            !rt_json.contains("{localId="),
12988            "localId attr syntax should not leak into ADF text: {rt_json}"
12989        );
12990    }
12991
12992    /// Issue #507: multiple taskItems with hardBreaks and numeric localIds.
12993    #[test]
12994    fn task_item_multiple_hardbreak_localids_roundtrip() {
12995        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"}]}]}]}]}"#;
12996        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12997        let md = adf_to_markdown(&doc).unwrap();
12998        assert!(md.contains("localId=42"), "localId=42 missing: {md}");
12999        assert!(md.contains("localId=67"), "localId=67 missing: {md}");
13000        let rt = markdown_to_adf(&md).unwrap();
13001        let items = rt.content[0].content.as_ref().unwrap();
13002        assert_eq!(items.len(), 2);
13003        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13004        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "67");
13005        // Verify hardBreak content structure for both items
13006        for item in items {
13007            let para = &item.content.as_ref().unwrap()[0];
13008            assert_eq!(para.node_type, "paragraph");
13009            let inlines = para.content.as_ref().unwrap();
13010            assert_eq!(inlines[1].node_type, "hardBreak");
13011        }
13012    }
13013
13014    /// Issue #521: sibling taskItems with numeric localIds and hardBreak —
13015    /// unwrapped inline content.  The hardBreak continuation line must be
13016    /// indented so it stays within the list item, and both localIds must
13017    /// survive the round-trip.
13018    #[test]
13019    fn task_item_sibling_localid_hardbreak_unwrapped_roundtrip() {
13020        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"}]}]}]}"#;
13021        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13022        let md = adf_to_markdown(&doc).unwrap();
13023        // Continuation line must be indented
13024        assert!(
13025            md.contains("  (parenthetical"),
13026            "continuation line should be 2-space indented: {md}"
13027        );
13028        assert!(md.contains("localId=42"), "localId=42 missing: {md}");
13029        assert!(md.contains("localId=69"), "localId=69 missing: {md}");
13030        let rt = markdown_to_adf(&md).unwrap();
13031        // Must remain a single taskList with 2 items
13032        assert_eq!(
13033            rt.content.len(),
13034            1,
13035            "should be one taskList: {:#?}",
13036            rt.content
13037        );
13038        assert_eq!(rt.content[0].node_type, "taskList");
13039        let items = rt.content[0].content.as_ref().unwrap();
13040        assert_eq!(items.len(), 2, "should have 2 taskItems");
13041        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13042        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "69");
13043        // Verify first item has hardBreak
13044        let first_content = items[0].content.as_ref().unwrap();
13045        assert!(
13046            first_content.iter().any(|n| n.node_type == "hardBreak"),
13047            "first item should contain hardBreak"
13048        );
13049        // Verify second item content
13050        let second_content = items[1].content.as_ref().unwrap();
13051        assert_eq!(second_content[0].node_type, "text");
13052        assert_eq!(
13053            second_content[0].text.as_deref().unwrap(),
13054            "second task item"
13055        );
13056    }
13057
13058    /// Issue #521: sibling taskItems with paragraph-wrapped content and
13059    /// hardBreak — localIds must not be swapped or lost.
13060    #[test]
13061    fn task_item_sibling_localid_hardbreak_paragraph_roundtrip() {
13062        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"}]}]}]}]}"#;
13063        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13064        let md = adf_to_markdown(&doc).unwrap();
13065        let rt = markdown_to_adf(&md).unwrap();
13066        assert_eq!(
13067            rt.content.len(),
13068            1,
13069            "should be one taskList: {:#?}",
13070            rt.content
13071        );
13072        let items = rt.content[0].content.as_ref().unwrap();
13073        assert_eq!(items.len(), 2);
13074        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13075        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "69");
13076    }
13077
13078    /// Issue #521: three sibling taskItems — the middle one has a hardBreak.
13079    /// Ensures localIds don't leak between adjacent items.
13080    #[test]
13081    fn task_item_three_siblings_middle_hardbreak_roundtrip() {
13082        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"}]}]}]}"#;
13083        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13084        let md = adf_to_markdown(&doc).unwrap();
13085        let rt = markdown_to_adf(&md).unwrap();
13086        assert_eq!(rt.content.len(), 1);
13087        let items = rt.content[0].content.as_ref().unwrap();
13088        assert_eq!(items.len(), 3);
13089        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "10");
13090        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "20");
13091        assert_eq!(items[2].attrs.as_ref().unwrap()["localId"], "30");
13092        // Middle item should have hardBreak
13093        let mid_content = items[1].content.as_ref().unwrap();
13094        assert!(mid_content.iter().any(|n| n.node_type == "hardBreak"));
13095    }
13096
13097    /// Issue #447: regression — taskList with empty localId must not inject
13098    /// a spurious paragraph.
13099    #[test]
13100    fn task_list_empty_localid_no_spurious_paragraph() {
13101        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"}]}]}]}"#;
13102        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13103        let md = adf_to_markdown(&doc).unwrap();
13104        assert!(
13105            !md.contains("{localId=}"),
13106            "empty localId should not be emitted: {md}"
13107        );
13108        let rt = markdown_to_adf(&md).unwrap();
13109        assert_eq!(
13110            rt.content.len(),
13111            1,
13112            "no spurious paragraph: {:#?}",
13113            rt.content
13114        );
13115        assert_eq!(rt.content[0].node_type, "taskList");
13116    }
13117
13118    /// Issue #447: taskList localId should be stripped when strip_local_ids is set.
13119    #[test]
13120    fn task_list_localid_stripped() {
13121        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"}]}]}]}]}"#;
13122        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13123        let opts = RenderOptions {
13124            strip_local_ids: true,
13125        };
13126        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13127        assert!(!md.contains("localId"), "localId should be stripped: {md}");
13128    }
13129
13130    /// Issue #447: taskItem with no content still emits localId.
13131    #[test]
13132    fn task_item_no_content_emits_localid() {
13133        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"}}]}]}"#;
13134        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13135        let md = adf_to_markdown(&doc).unwrap();
13136        assert!(
13137            md.contains("localId=abc"),
13138            "localId should be emitted even without content: {md}"
13139        );
13140        let rt = markdown_to_adf(&md).unwrap();
13141        let item = &rt.content[0].content.as_ref().unwrap()[0];
13142        assert_eq!(item.attrs.as_ref().unwrap()["localId"], "abc");
13143        assert!(item.content.is_none(), "should have no content");
13144    }
13145
13146    /// Issue #447: taskList localId roundtrips through block attrs.
13147    #[test]
13148    fn task_list_localid_roundtrip() {
13149        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"}]}]}]}]}"#;
13150        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13151        let md = adf_to_markdown(&doc).unwrap();
13152        assert!(
13153            md.contains("localId=tl-xyz"),
13154            "taskList localId missing: {md}"
13155        );
13156        let rt = markdown_to_adf(&md).unwrap();
13157        assert_eq!(
13158            rt.content[0].attrs.as_ref().unwrap()["localId"],
13159            "tl-xyz",
13160            "taskList localId should survive round-trip"
13161        );
13162    }
13163
13164    /// Issue #478: taskItem with paragraph wrapper (no localId) preserves wrapper on round-trip.
13165    #[test]
13166    fn task_item_paragraph_wrapper_roundtrip_no_localid() {
13167        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"}]}]}]}]}"#;
13168        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13169        let md = adf_to_markdown(&doc).unwrap();
13170        assert!(
13171            md.contains("paraLocalId=_"),
13172            "should emit paraLocalId=_ sentinel: {md}"
13173        );
13174        let rt = markdown_to_adf(&md).unwrap();
13175        let item = &rt.content[0].content.as_ref().unwrap()[0];
13176        let content = item.content.as_ref().unwrap();
13177        assert_eq!(content.len(), 1, "should have one child: {content:#?}");
13178        assert_eq!(
13179            content[0].node_type, "paragraph",
13180            "child should be a paragraph: {content:#?}"
13181        );
13182        let para_content = content[0].content.as_ref().unwrap();
13183        assert_eq!(
13184            para_content[0].text.as_deref(),
13185            Some("A task with paragraph wrapper")
13186        );
13187        // Paragraph should have no attrs (localId was absent in the original)
13188        assert!(
13189            content[0].attrs.is_none(),
13190            "paragraph should have no attrs: {:?}",
13191            content[0].attrs
13192        );
13193    }
13194
13195    /// Issue #478: taskItem with paragraph wrapper AND paraLocalId preserves both.
13196    #[test]
13197    fn task_item_paragraph_wrapper_roundtrip_with_localid() {
13198        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"}]}]}]}]}"#;
13199        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13200        let md = adf_to_markdown(&doc).unwrap();
13201        assert!(
13202            md.contains("paraLocalId=p-001"),
13203            "should emit paraLocalId=p-001: {md}"
13204        );
13205        let rt = markdown_to_adf(&md).unwrap();
13206        let item = &rt.content[0].content.as_ref().unwrap()[0];
13207        let content = item.content.as_ref().unwrap();
13208        assert_eq!(content[0].node_type, "paragraph");
13209        assert_eq!(
13210            content[0].attrs.as_ref().unwrap()["localId"],
13211            "p-001",
13212            "paragraph localId should be preserved"
13213        );
13214    }
13215
13216    /// Issue #478: taskItem WITHOUT paragraph wrapper (unwrapped inline) still round-trips correctly.
13217    #[test]
13218    fn task_item_unwrapped_inline_no_paragraph_on_roundtrip() {
13219        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"}]}]}]}"#;
13220        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13221        let md = adf_to_markdown(&doc).unwrap();
13222        assert!(
13223            !md.contains("paraLocalId"),
13224            "should NOT emit paraLocalId for unwrapped inline: {md}"
13225        );
13226        let rt = markdown_to_adf(&md).unwrap();
13227        let item = &rt.content[0].content.as_ref().unwrap()[0];
13228        let content = item.content.as_ref().unwrap();
13229        assert_eq!(
13230            content[0].node_type, "text",
13231            "should remain unwrapped: {content:#?}"
13232        );
13233    }
13234
13235    /// Issue #478: DONE taskItem with paragraph wrapper round-trips.
13236    #[test]
13237    fn task_item_done_paragraph_wrapper_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":"DONE"},"content":[{"type":"paragraph","content":[{"type":"text","text":"completed task"}]}]}]}]}"#;
13239        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13240        let md = adf_to_markdown(&doc).unwrap();
13241        assert!(md.contains("- [x]"), "should render as done: {md}");
13242        let rt = markdown_to_adf(&md).unwrap();
13243        let item = &rt.content[0].content.as_ref().unwrap()[0];
13244        assert_eq!(item.attrs.as_ref().unwrap()["state"], "DONE");
13245        let content = item.content.as_ref().unwrap();
13246        assert_eq!(content[0].node_type, "paragraph");
13247    }
13248
13249    /// Issue #478: mixed taskItems — some with paragraph wrapper, some without.
13250    #[test]
13251    fn task_item_mixed_paragraph_and_unwrapped_roundtrip() {
13252        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"}]}]}]}"#;
13253        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13254        let md = adf_to_markdown(&doc).unwrap();
13255        let rt = markdown_to_adf(&md).unwrap();
13256        let items = rt.content[0].content.as_ref().unwrap();
13257        assert_eq!(items.len(), 2);
13258        // First item: paragraph wrapper preserved
13259        let c1 = items[0].content.as_ref().unwrap();
13260        assert_eq!(
13261            c1[0].node_type, "paragraph",
13262            "first item should have paragraph wrapper"
13263        );
13264        // Second item: no paragraph wrapper
13265        let c2 = items[1].content.as_ref().unwrap();
13266        assert_eq!(
13267            c2[0].node_type, "text",
13268            "second item should remain unwrapped"
13269        );
13270    }
13271
13272    /// Issue #478: taskItem with paragraph wrapper containing marks round-trips.
13273    #[test]
13274    fn task_item_paragraph_wrapper_with_marks_roundtrip() {
13275        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"}]}]}]}]}]}"#;
13276        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13277        let md = adf_to_markdown(&doc).unwrap();
13278        let rt = markdown_to_adf(&md).unwrap();
13279        let item = &rt.content[0].content.as_ref().unwrap()[0];
13280        let content = item.content.as_ref().unwrap();
13281        assert_eq!(content[0].node_type, "paragraph");
13282        let para_children = content[0].content.as_ref().unwrap();
13283        assert!(
13284            para_children.len() >= 2,
13285            "paragraph should contain multiple inline nodes"
13286        );
13287    }
13288
13289    /// Issue #478: strip_local_ids suppresses the paraLocalId=_ sentinel too.
13290    #[test]
13291    fn task_item_paragraph_wrapper_stripped_with_option() {
13292        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"}]}]}]}]}"#;
13293        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13294        let opts = RenderOptions {
13295            strip_local_ids: true,
13296        };
13297        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13298        assert!(
13299            !md.contains("paraLocalId"),
13300            "paraLocalId should be stripped: {md}"
13301        );
13302        assert!(
13303            !md.contains("localId"),
13304            "all localIds should be stripped: {md}"
13305        );
13306    }
13307
13308    #[test]
13309    fn trailing_space_preserved_with_hex_localid() {
13310        // Issue #449: trailing whitespace stripped from text node
13311        // when listItem has a hex-format localId (no hyphens)
13312        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 "}]}]}]}]}"#;
13313        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13314        let md = adf_to_markdown(&doc).unwrap();
13315        let rt = markdown_to_adf(&md).unwrap();
13316        let item = &rt.content[0].content.as_ref().unwrap()[0];
13317        assert_eq!(
13318            item.attrs.as_ref().unwrap()["localId"],
13319            "aabb112233cc",
13320            "localId should round-trip"
13321        );
13322        let para = &item.content.as_ref().unwrap()[0];
13323        let inlines = para.content.as_ref().unwrap();
13324        let last = inlines.last().unwrap();
13325        assert!(
13326            last.text.as_deref().unwrap_or("").ends_with(' '),
13327            "trailing space should be preserved, got nodes: {:?}",
13328            inlines
13329                .iter()
13330                .map(|n| (&n.node_type, &n.text))
13331                .collect::<Vec<_>>()
13332        );
13333    }
13334
13335    #[test]
13336    fn extract_trailing_local_id_preserves_trailing_space() {
13337        // Issue #449: only strip the single separator space before {localId=...}
13338        let (before, lid, _) = extract_trailing_local_id("trailing space  {localId=aabb112233cc}");
13339        assert_eq!(before, "trailing space ");
13340        assert_eq!(lid.as_deref(), Some("aabb112233cc"));
13341    }
13342
13343    #[test]
13344    fn extract_trailing_local_id_no_trailing_space() {
13345        let (before, lid, _) = extract_trailing_local_id("text {localId=abc123}");
13346        assert_eq!(before, "text");
13347        assert_eq!(lid.as_deref(), Some("abc123"));
13348    }
13349
13350    #[test]
13351    fn extract_trailing_local_id_no_attrs() {
13352        let (before, lid, pid) = extract_trailing_local_id("plain text");
13353        assert_eq!(before, "plain text");
13354        assert!(lid.is_none());
13355        assert!(pid.is_none());
13356    }
13357
13358    #[test]
13359    fn list_item_localid_stripped() {
13360        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"}]}]}]}]}"#;
13361        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13362        let opts = RenderOptions {
13363            strip_local_ids: true,
13364        };
13365        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13366        assert!(!md.contains("localId"), "localId should be stripped: {md}");
13367    }
13368
13369    #[test]
13370    fn paragraph_localid_in_list_item_roundtrip() {
13371        // Issue #417: paragraph.attrs.localId dropped in listItem context
13372        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"}]}]}]}]}"#;
13373        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13374        let md = adf_to_markdown(&doc).unwrap();
13375        assert!(
13376            md.contains("paraLocalId=para-001"),
13377            "paragraph localId should be in md: {md}"
13378        );
13379        let rt = markdown_to_adf(&md).unwrap();
13380        let item = &rt.content[0].content.as_ref().unwrap()[0];
13381        assert_eq!(
13382            item.attrs.as_ref().unwrap()["localId"],
13383            "item-001",
13384            "listItem localId should survive"
13385        );
13386        let para = &item.content.as_ref().unwrap()[0];
13387        assert_eq!(
13388            para.attrs.as_ref().unwrap()["localId"],
13389            "para-001",
13390            "paragraph localId should survive round-trip"
13391        );
13392    }
13393
13394    #[test]
13395    fn paragraph_localid_in_ordered_list_item_roundtrip() {
13396        // Issue #417: paragraph localId in ordered list
13397        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"}]}]}]}]}"#;
13398        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13399        let md = adf_to_markdown(&doc).unwrap();
13400        assert!(md.contains("paraLocalId=op-001"), "md: {md}");
13401        let rt = markdown_to_adf(&md).unwrap();
13402        let item = &rt.content[0].content.as_ref().unwrap()[0];
13403        assert_eq!(item.attrs.as_ref().unwrap()["localId"], "oli-001");
13404        let para = &item.content.as_ref().unwrap()[0];
13405        assert_eq!(para.attrs.as_ref().unwrap()["localId"], "op-001");
13406    }
13407
13408    #[test]
13409    fn paragraph_localid_only_in_list_item() {
13410        // paragraph has localId but listItem does not
13411        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"}]}]}]}]}"#;
13412        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13413        let md = adf_to_markdown(&doc).unwrap();
13414        assert!(
13415            md.contains("paraLocalId=para-only"),
13416            "paragraph localId should be emitted: {md}"
13417        );
13418        let rt = markdown_to_adf(&md).unwrap();
13419        let item = &rt.content[0].content.as_ref().unwrap()[0];
13420        assert!(item.attrs.is_none(), "listItem should have no attrs");
13421        let para = &item.content.as_ref().unwrap()[0];
13422        assert_eq!(para.attrs.as_ref().unwrap()["localId"], "para-only");
13423    }
13424
13425    #[test]
13426    fn paragraph_localid_in_table_header_roundtrip() {
13427        // Issue #417: paragraph.attrs.localId dropped in tableHeader context
13428        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"}]}]}]}]}]}"#;
13429        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13430        let md = adf_to_markdown(&doc).unwrap();
13431        // Should use directive form (not pipe table) to preserve paragraph localId
13432        assert!(
13433            md.contains("localId=aaaa-aaaa"),
13434            "paragraph localId should be in md: {md}"
13435        );
13436        let rt = markdown_to_adf(&md).unwrap();
13437        let cell = &rt.content[0].content.as_ref().unwrap()[0]
13438            .content
13439            .as_ref()
13440            .unwrap()[0];
13441        let para = &cell.content.as_ref().unwrap()[0];
13442        assert_eq!(
13443            para.attrs.as_ref().unwrap()["localId"],
13444            "aaaa-aaaa",
13445            "paragraph localId should survive round-trip in tableHeader"
13446        );
13447    }
13448
13449    #[test]
13450    fn paragraph_localid_in_table_cell_roundtrip() {
13451        // Issue #417: paragraph localId in tableCell forces directive table
13452        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"}]}]}]}]}]}"#;
13453        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13454        let md = adf_to_markdown(&doc).unwrap();
13455        assert!(
13456            md.contains("localId=cell-para"),
13457            "paragraph localId should be in md: {md}"
13458        );
13459        let rt = markdown_to_adf(&md).unwrap();
13460        // Data row -> cell -> paragraph
13461        let cell = &rt.content[0].content.as_ref().unwrap()[1]
13462            .content
13463            .as_ref()
13464            .unwrap()[0];
13465        let para = &cell.content.as_ref().unwrap()[0];
13466        assert_eq!(
13467            para.attrs.as_ref().unwrap()["localId"],
13468            "cell-para",
13469            "paragraph localId should survive round-trip in tableCell"
13470        );
13471    }
13472
13473    #[test]
13474    fn nbsp_paragraph_with_localid_roundtrip() {
13475        // Issue #417: nbsp paragraph localId emitted as text instead of attrs
13476        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"nbsp-para"},"content":[{"type":"text","text":"\u00a0"}]}]}"#;
13477        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13478        let md = adf_to_markdown(&doc).unwrap();
13479        assert!(
13480            md.contains("::paragraph["),
13481            "nbsp should use directive form: {md}"
13482        );
13483        assert!(
13484            md.contains("localId=nbsp-para"),
13485            "localId should be in directive: {md}"
13486        );
13487        let rt = markdown_to_adf(&md).unwrap();
13488        let para = &rt.content[0];
13489        assert_eq!(
13490            para.attrs.as_ref().unwrap()["localId"],
13491            "nbsp-para",
13492            "localId should survive round-trip"
13493        );
13494        let text = para.content.as_ref().unwrap()[0].text.as_ref().unwrap();
13495        assert_eq!(text, "\u{00a0}", "nbsp should survive");
13496    }
13497
13498    #[test]
13499    fn empty_paragraph_with_localid_roundtrip() {
13500        // Empty paragraph directive with localId
13501        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"empty-para"}}]}"#;
13502        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13503        let md = adf_to_markdown(&doc).unwrap();
13504        assert!(
13505            md.contains("::paragraph{localId=empty-para}"),
13506            "empty paragraph should include localId in directive: {md}"
13507        );
13508        let rt = markdown_to_adf(&md).unwrap();
13509        assert_eq!(
13510            rt.content[0].attrs.as_ref().unwrap()["localId"],
13511            "empty-para"
13512        );
13513    }
13514
13515    #[test]
13516    fn paragraph_localid_stripped_from_list_item() {
13517        // strip_local_ids should also strip paraLocalId
13518        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"}]}]}]}]}"#;
13519        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13520        let opts = RenderOptions {
13521            strip_local_ids: true,
13522        };
13523        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13524        assert!(!md.contains("localId"), "localId should be stripped: {md}");
13525        assert!(
13526            !md.contains("paraLocalId"),
13527            "paraLocalId should be stripped: {md}"
13528        );
13529    }
13530
13531    #[test]
13532    fn date_directive() {
13533        let doc = markdown_to_adf("Due by :date[2026-04-15].").unwrap();
13534        let content = doc.content[0].content.as_ref().unwrap();
13535        assert_eq!(content[1].node_type, "date");
13536        // ISO date is converted to epoch milliseconds
13537        assert_eq!(
13538            content[1].attrs.as_ref().unwrap()["timestamp"],
13539            "1776211200000"
13540        );
13541    }
13542
13543    #[test]
13544    fn adf_date_to_markdown() {
13545        // ADF dates use epoch ms; renderer converts back to ISO with timestamp attr
13546        let doc = AdfDocument {
13547            version: 1,
13548            doc_type: "doc".to_string(),
13549            content: vec![AdfNode::paragraph(vec![AdfNode::date("1776211200000")])],
13550        };
13551        let md = adf_to_markdown(&doc).unwrap();
13552        assert!(md.contains(":date[2026-04-15]{timestamp=1776211200000}"));
13553    }
13554
13555    #[test]
13556    fn adf_date_iso_passthrough() {
13557        // If ADF already has ISO date (legacy), pass through
13558        let doc = AdfDocument {
13559            version: 1,
13560            doc_type: "doc".to_string(),
13561            content: vec![AdfNode::paragraph(vec![AdfNode::date("2026-04-15")])],
13562        };
13563        let md = adf_to_markdown(&doc).unwrap();
13564        assert!(md.contains(":date[2026-04-15]{timestamp=2026-04-15}"));
13565    }
13566
13567    #[test]
13568    fn round_trip_date() {
13569        let md = "Due by :date[2026-04-15].\n";
13570        let doc = markdown_to_adf(md).unwrap();
13571        let result = adf_to_markdown(&doc).unwrap();
13572        assert!(result.contains(":date[2026-04-15]"));
13573    }
13574
13575    #[test]
13576    fn round_trip_date_non_midnight_timestamp() {
13577        // Issue #409: non-midnight timestamps must survive round-trip
13578        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000"}}]}]}"#;
13579        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13580        let md = adf_to_markdown(&doc).unwrap();
13581        // JFM should include the original timestamp
13582        assert!(
13583            md.contains("timestamp=1700000000000"),
13584            "JFM should preserve original timestamp: {md}"
13585        );
13586        // Round-trip back to ADF
13587        let doc2 = markdown_to_adf(&md).unwrap();
13588        let content = doc2.content[0].content.as_ref().unwrap();
13589        assert_eq!(
13590            content[0].attrs.as_ref().unwrap()["timestamp"],
13591            "1700000000000",
13592            "Round-trip must preserve original non-midnight timestamp"
13593        );
13594    }
13595
13596    #[test]
13597    fn date_epoch_ms_passthrough() {
13598        // If JFM date is already epoch ms, pass through
13599        let doc = markdown_to_adf("Due by :date[1776211200000].").unwrap();
13600        let content = doc.content[0].content.as_ref().unwrap();
13601        assert_eq!(
13602            content[1].attrs.as_ref().unwrap()["timestamp"],
13603            "1776211200000"
13604        );
13605    }
13606
13607    #[test]
13608    fn date_timestamp_attr_preferred_over_content() {
13609        // When timestamp attr is present, it takes priority over the display date
13610        let md = ":date[2023-11-14]{timestamp=1700000000000}\n";
13611        let doc = markdown_to_adf(md).unwrap();
13612        let content = doc.content[0].content.as_ref().unwrap();
13613        assert_eq!(
13614            content[0].attrs.as_ref().unwrap()["timestamp"],
13615            "1700000000000",
13616            "timestamp attr should be used directly"
13617        );
13618    }
13619
13620    #[test]
13621    fn date_without_timestamp_attr_backward_compat() {
13622        // Legacy JFM without timestamp attr still works via iso_date_to_epoch_ms
13623        let md = ":date[2026-04-15]\n";
13624        let doc = markdown_to_adf(md).unwrap();
13625        let content = doc.content[0].content.as_ref().unwrap();
13626        assert_eq!(
13627            content[0].attrs.as_ref().unwrap()["timestamp"],
13628            "1776211200000",
13629            "Should fall back to computing timestamp from date string"
13630        );
13631    }
13632
13633    #[test]
13634    fn date_with_local_id_and_timestamp() {
13635        // Both localId and timestamp should round-trip
13636        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
13637        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13638        let md = adf_to_markdown(&doc).unwrap();
13639        assert!(
13640            md.contains("timestamp=1700000000000"),
13641            "Should contain timestamp: {md}"
13642        );
13643        assert!(md.contains("localId=d-001"), "Should contain localId: {md}");
13644        // Round-trip
13645        let doc2 = markdown_to_adf(&md).unwrap();
13646        let content = doc2.content[0].content.as_ref().unwrap();
13647        let attrs = content[0].attrs.as_ref().unwrap();
13648        assert_eq!(attrs["timestamp"], "1700000000000");
13649        assert_eq!(attrs["localId"], "d-001");
13650    }
13651
13652    #[test]
13653    fn mention_directive() {
13654        let doc = markdown_to_adf("Assigned to :mention[Alice]{id=abc123}.").unwrap();
13655        let content = doc.content[0].content.as_ref().unwrap();
13656        assert_eq!(content[1].node_type, "mention");
13657        assert_eq!(content[1].attrs.as_ref().unwrap()["id"], "abc123");
13658        assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "Alice");
13659    }
13660
13661    #[test]
13662    fn adf_mention_to_markdown() {
13663        let doc = AdfDocument {
13664            version: 1,
13665            doc_type: "doc".to_string(),
13666            content: vec![AdfNode::paragraph(vec![AdfNode::mention(
13667                "abc123", "Alice",
13668            )])],
13669        };
13670        let md = adf_to_markdown(&doc).unwrap();
13671        assert!(md.contains(":mention[Alice]{id=abc123}"));
13672    }
13673
13674    #[test]
13675    fn round_trip_mention() {
13676        let md = "Assigned to :mention[Alice]{id=abc123}.\n";
13677        let doc = markdown_to_adf(md).unwrap();
13678        let result = adf_to_markdown(&doc).unwrap();
13679        assert!(result.contains(":mention[Alice]{id=abc123}"));
13680    }
13681
13682    #[test]
13683    fn mention_with_empty_access_level_round_trips() {
13684        // Issue #363: accessLevel="" produces accessLevel= which failed to parse
13685        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13686          {"type":"mention","attrs":{"id":"61921b41c15977006af2b1d1","text":"@Javier Inchausti","accessLevel":""}}
13687        ]}]}"#;
13688        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13689
13690        let md = adf_to_markdown(&doc).unwrap();
13691        let round_tripped = markdown_to_adf(&md).unwrap();
13692        let mention = &round_tripped.content[0].content.as_ref().unwrap()[0];
13693        assert_eq!(
13694            mention.node_type, "mention",
13695            "mention with empty accessLevel was not parsed as mention, got: {}",
13696            mention.node_type
13697        );
13698    }
13699
13700    #[test]
13701    fn span_with_color() {
13702        let doc = markdown_to_adf("This is :span[red text]{color=#ff5630}.").unwrap();
13703        let content = doc.content[0].content.as_ref().unwrap();
13704        assert_eq!(content[1].node_type, "text");
13705        assert_eq!(content[1].text.as_deref(), Some("red text"));
13706        let marks = content[1].marks.as_ref().unwrap();
13707        assert_eq!(marks[0].mark_type, "textColor");
13708    }
13709
13710    #[test]
13711    fn adf_text_color_to_markdown() {
13712        let doc = AdfDocument {
13713            version: 1,
13714            doc_type: "doc".to_string(),
13715            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
13716                "red text",
13717                vec![AdfMark::text_color("#ff5630")],
13718            )])],
13719        };
13720        let md = adf_to_markdown(&doc).unwrap();
13721        assert!(md.contains(":span[red text]{color=#ff5630}"));
13722    }
13723
13724    #[test]
13725    fn round_trip_span_color() {
13726        let md = "This is :span[red text]{color=#ff5630}.\n";
13727        let doc = markdown_to_adf(md).unwrap();
13728        let result = adf_to_markdown(&doc).unwrap();
13729        assert!(result.contains(":span[red text]{color=#ff5630}"));
13730    }
13731
13732    #[test]
13733    fn text_color_and_link_marks_both_preserved() {
13734        // Issue #405: text with both textColor and link marks loses link on round-trip
13735        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13736          {"type":"text","text":"red link","marks":[
13737            {"type":"link","attrs":{"href":"https://example.com"}},
13738            {"type":"textColor","attrs":{"color":"#ff0000"}}
13739          ]}
13740        ]}]}"##;
13741        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13742        let md = adf_to_markdown(&doc).unwrap();
13743        assert!(
13744            md.contains(":span[red link]{color=#ff0000}"),
13745            "JFM should contain span with color, got: {md}"
13746        );
13747        assert!(
13748            md.contains("](https://example.com)"),
13749            "JFM should contain link href, got: {md}"
13750        );
13751        // Full round-trip: both marks survive
13752        let rt = markdown_to_adf(&md).unwrap();
13753        let text_node = &rt.content[0].content.as_ref().unwrap()[0];
13754        let marks = text_node.marks.as_ref().expect("should have marks");
13755        assert!(
13756            marks.iter().any(|m| m.mark_type == "textColor"),
13757            "should have textColor mark, got: {:?}",
13758            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
13759        );
13760        assert!(
13761            marks.iter().any(|m| m.mark_type == "link"),
13762            "should have link mark, got: {:?}",
13763            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
13764        );
13765        // Verify attribute values survive
13766        let link_mark = marks.iter().find(|m| m.mark_type == "link").unwrap();
13767        assert_eq!(
13768            link_mark.attrs.as_ref().unwrap()["href"],
13769            "https://example.com"
13770        );
13771        let color_mark = marks.iter().find(|m| m.mark_type == "textColor").unwrap();
13772        assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#ff0000");
13773    }
13774
13775    #[test]
13776    fn bg_color_and_link_marks_both_preserved() {
13777        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13778          {"type":"text","text":"highlighted link","marks":[
13779            {"type":"link","attrs":{"href":"https://example.com"}},
13780            {"type":"backgroundColor","attrs":{"color":"#ffff00"}}
13781          ]}
13782        ]}]}"##;
13783        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13784        let md = adf_to_markdown(&doc).unwrap();
13785        assert!(md.contains("bg=#ffff00"), "should have bg color: {md}");
13786        assert!(
13787            md.contains("](https://example.com)"),
13788            "should have link: {md}"
13789        );
13790        let rt = markdown_to_adf(&md).unwrap();
13791        let text_node = &rt.content[0].content.as_ref().unwrap()[0];
13792        let marks = text_node.marks.as_ref().expect("should have marks");
13793        assert!(marks.iter().any(|m| m.mark_type == "backgroundColor"));
13794        assert!(marks.iter().any(|m| m.mark_type == "link"));
13795    }
13796
13797    #[test]
13798    fn text_color_link_and_strong_rendering() {
13799        // Verify textColor + link + strong renders all three formatting elements
13800        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13801          {"type":"text","text":"bold red link","marks":[
13802            {"type":"strong"},
13803            {"type":"link","attrs":{"href":"https://example.com"}},
13804            {"type":"textColor","attrs":{"color":"#ff0000"}}
13805          ]}
13806        ]}]}"##;
13807        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13808        let md = adf_to_markdown(&doc).unwrap();
13809        assert!(
13810            md.starts_with("**") && md.trim().ends_with("**"),
13811            "should have bold wrapping: {md}"
13812        );
13813        assert!(md.contains("color=#ff0000"), "should have color: {md}");
13814        assert!(
13815            md.contains("](https://example.com)"),
13816            "should have link: {md}"
13817        );
13818    }
13819
13820    #[test]
13821    fn subsup_and_link_marks_both_preserved() {
13822        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13823          {"type":"text","text":"note","marks":[
13824            {"type":"link","attrs":{"href":"https://example.com"}},
13825            {"type":"subsup","attrs":{"type":"sup"}}
13826          ]}
13827        ]}]}"#;
13828        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13829        let md = adf_to_markdown(&doc).unwrap();
13830        assert!(md.contains("sup"), "should have sup: {md}");
13831        assert!(
13832            md.contains("](https://example.com)"),
13833            "should have link: {md}"
13834        );
13835        let rt = markdown_to_adf(&md).unwrap();
13836        let text_node = &rt.content[0].content.as_ref().unwrap()[0];
13837        let marks = text_node.marks.as_ref().expect("should have marks");
13838        assert!(marks.iter().any(|m| m.mark_type == "subsup"));
13839        assert!(marks.iter().any(|m| m.mark_type == "link"));
13840    }
13841
13842    #[test]
13843    fn text_color_without_link_unchanged() {
13844        // Regression guard: textColor without link should still work
13845        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13846          {"type":"text","text":"just red","marks":[
13847            {"type":"textColor","attrs":{"color":"#ff0000"}}
13848          ]}
13849        ]}]}"##;
13850        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13851        let md = adf_to_markdown(&doc).unwrap();
13852        assert!(md.contains(":span[just red]{color=#ff0000}"), "md: {md}");
13853        assert!(!md.contains("](http"), "should NOT have link syntax: {md}");
13854    }
13855
13856    #[test]
13857    fn inline_extension_directive() {
13858        let doc =
13859            markdown_to_adf("See :extension[fallback]{type=com.app key=widget} here.").unwrap();
13860        let content = doc.content[0].content.as_ref().unwrap();
13861        assert_eq!(content[1].node_type, "inlineExtension");
13862        assert_eq!(
13863            content[1].attrs.as_ref().unwrap()["extensionType"],
13864            "com.app"
13865        );
13866        assert_eq!(content[1].attrs.as_ref().unwrap()["extensionKey"], "widget");
13867    }
13868
13869    #[test]
13870    fn adf_inline_extension_to_markdown() {
13871        let doc = AdfDocument {
13872            version: 1,
13873            doc_type: "doc".to_string(),
13874            content: vec![AdfNode::paragraph(vec![AdfNode::inline_extension(
13875                "com.app",
13876                "widget",
13877                Some("fallback"),
13878            )])],
13879        };
13880        let md = adf_to_markdown(&doc).unwrap();
13881        assert!(md.contains(":extension[fallback]{type=com.app key=widget}"));
13882    }
13883
13884    // ── Helper function tests ──────────────────────────────────────────
13885
13886    #[test]
13887    fn parse_ordered_list_marker_valid() {
13888        let result = parse_ordered_list_marker("1. Hello");
13889        assert_eq!(result, Some((1, "Hello")));
13890    }
13891
13892    #[test]
13893    fn parse_ordered_list_marker_high_number() {
13894        let result = parse_ordered_list_marker("42. Item");
13895        assert_eq!(result, Some((42, "Item")));
13896    }
13897
13898    #[test]
13899    fn parse_ordered_list_marker_not_a_list() {
13900        assert!(parse_ordered_list_marker("not a list").is_none());
13901        assert!(parse_ordered_list_marker("1.no space").is_none());
13902    }
13903
13904    #[test]
13905    fn is_list_start_various() {
13906        assert!(is_list_start("- item"));
13907        assert!(is_list_start("* item"));
13908        assert!(is_list_start("+ item"));
13909        assert!(is_list_start("1. item"));
13910        assert!(!is_list_start("not a list"));
13911    }
13912
13913    #[test]
13914    fn is_horizontal_rule_various() {
13915        assert!(is_horizontal_rule("---"));
13916        assert!(is_horizontal_rule("***"));
13917        assert!(is_horizontal_rule("___"));
13918        assert!(is_horizontal_rule("------"));
13919        assert!(!is_horizontal_rule("--"));
13920        assert!(!is_horizontal_rule("abc"));
13921    }
13922
13923    #[test]
13924    fn is_table_separator_valid() {
13925        assert!(is_table_separator("| --- | --- |"));
13926        assert!(is_table_separator("|:---:|:---|"));
13927        assert!(!is_table_separator("no pipes here"));
13928    }
13929
13930    #[test]
13931    fn parse_table_row_cells() {
13932        let cells = parse_table_row("| A | B | C |");
13933        assert_eq!(cells, vec!["A", "B", "C"]);
13934    }
13935
13936    #[test]
13937    fn parse_table_row_escaped_pipe_in_cell() {
13938        // Issue #579: `\|` inside a cell is a literal pipe, not a column separator.
13939        let cells = parse_table_row(r"| a\|b | c |");
13940        assert_eq!(cells, vec!["a|b", "c"]);
13941    }
13942
13943    #[test]
13944    fn parse_table_row_escaped_pipe_in_code_span() {
13945        // Issue #579: `\|` inside an inline code span is unescaped at the row level.
13946        let cells = parse_table_row(r"| `parser.decode[T\|json]` | other |");
13947        assert_eq!(cells, vec!["`parser.decode[T|json]`", "other"]);
13948    }
13949
13950    #[test]
13951    fn parse_table_row_preserves_other_backslashes() {
13952        // Only `\|` is special at the row-splitting level; other backslashes pass through.
13953        let cells = parse_table_row(r"| a\\b | c\*d |");
13954        assert_eq!(cells, vec![r"a\\b", r"c\*d"]);
13955    }
13956
13957    #[test]
13958    fn parse_image_syntax_valid() {
13959        let result = parse_image_syntax("![alt](url)");
13960        assert_eq!(result, Some(("alt", "url")));
13961    }
13962
13963    #[test]
13964    fn parse_image_syntax_not_image() {
13965        assert!(parse_image_syntax("not an image").is_none());
13966    }
13967
13968    // ── find_closing_paren tests ────────────────────────────────────
13969
13970    #[test]
13971    fn find_closing_paren_simple() {
13972        assert_eq!(find_closing_paren("(hello)", 0), Some(6));
13973    }
13974
13975    #[test]
13976    fn find_closing_paren_nested() {
13977        assert_eq!(find_closing_paren("(a(b)c)", 0), Some(6));
13978    }
13979
13980    #[test]
13981    fn find_closing_paren_unmatched() {
13982        assert_eq!(find_closing_paren("(no close", 0), None);
13983    }
13984
13985    #[test]
13986    fn find_closing_paren_offset() {
13987        // Start scanning from the second '('
13988        assert_eq!(find_closing_paren("xx(inner)", 2), Some(8));
13989    }
13990
13991    // ── Parentheses-in-URL tests (issue #509) ──────────────────────
13992
13993    #[test]
13994    fn try_parse_link_url_with_parens() {
13995        let input = "[here](https://example.com/faq#access-(permissions)-rest)";
13996        let result = try_parse_link(input, 0);
13997        assert_eq!(
13998            result,
13999            Some((
14000                input.len(),
14001                "here",
14002                "https://example.com/faq#access-(permissions)-rest"
14003            ))
14004        );
14005    }
14006
14007    #[test]
14008    fn try_parse_link_url_no_parens() {
14009        let input = "[text](https://example.com)";
14010        let result = try_parse_link(input, 0);
14011        assert_eq!(result, Some((input.len(), "text", "https://example.com")));
14012    }
14013
14014    #[test]
14015    fn try_parse_link_url_with_multiple_nested_parens() {
14016        let input = "[x](http://en.wikipedia.org/wiki/Foo_(bar_(baz)))";
14017        let result = try_parse_link(input, 0);
14018        assert_eq!(
14019            result,
14020            Some((
14021                input.len(),
14022                "x",
14023                "http://en.wikipedia.org/wiki/Foo_(bar_(baz))"
14024            ))
14025        );
14026    }
14027
14028    #[test]
14029    fn parse_image_syntax_url_with_parens() {
14030        let result = parse_image_syntax("![alt](https://example.com/page_(1))");
14031        assert_eq!(result, Some(("alt", "https://example.com/page_(1)")));
14032    }
14033
14034    #[test]
14035    fn parse_image_syntax_url_no_parens() {
14036        let result = parse_image_syntax("![alt](https://example.com)");
14037        assert_eq!(result, Some(("alt", "https://example.com")));
14038    }
14039
14040    #[test]
14041    fn link_with_parens_round_trip() {
14042        let href = "https://example.com/faq#I-need-access-(permissions)-added-in-Monitor";
14043        let mut text_node = AdfNode::text("here");
14044        text_node.marks = Some(vec![AdfMark::link(href)]);
14045        let adf_input = AdfDocument {
14046            version: 1,
14047            doc_type: "doc".to_string(),
14048            content: vec![AdfNode::paragraph(vec![text_node])],
14049        };
14050
14051        let jfm = adf_to_markdown(&adf_input).unwrap();
14052        let adf_output = markdown_to_adf(&jfm).unwrap();
14053
14054        // Extract the href from the round-tripped ADF
14055        let para = &adf_output.content[0];
14056        let text_node = &para.content.as_ref().unwrap()[0];
14057        let mark = &text_node.marks.as_ref().unwrap()[0];
14058        let result_href = mark.attrs.as_ref().unwrap()["href"].as_str().unwrap();
14059
14060        assert_eq!(result_href, href);
14061    }
14062
14063    #[test]
14064    fn flush_plain_empty_range() {
14065        let mut nodes = Vec::new();
14066        flush_plain("hello", 3, 3, &mut nodes);
14067        assert!(nodes.is_empty());
14068    }
14069
14070    #[test]
14071    fn add_mark_to_unmarked_node() {
14072        let mut node = AdfNode::text("test");
14073        add_mark(&mut node, AdfMark::strong());
14074        assert_eq!(node.marks.as_ref().unwrap().len(), 1);
14075    }
14076
14077    #[test]
14078    fn add_mark_to_marked_node() {
14079        let mut node = AdfNode::text_with_marks("test", vec![AdfMark::strong()]);
14080        add_mark(&mut node, AdfMark::em());
14081        assert_eq!(node.marks.as_ref().unwrap().len(), 2);
14082    }
14083
14084    // ── Directive table tests ──────────────────────────────────────
14085
14086    #[test]
14087    fn directive_table_basic() {
14088        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";
14089        let doc = markdown_to_adf(md).unwrap();
14090        assert_eq!(doc.content[0].node_type, "table");
14091        let rows = doc.content[0].content.as_ref().unwrap();
14092        assert_eq!(rows.len(), 2);
14093        assert_eq!(
14094            rows[0].content.as_ref().unwrap()[0].node_type,
14095            "tableHeader"
14096        );
14097        assert_eq!(rows[1].content.as_ref().unwrap()[0].node_type, "tableCell");
14098    }
14099
14100    #[test]
14101    fn directive_table_with_block_content() {
14102        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";
14103        let doc = markdown_to_adf(md).unwrap();
14104        let rows = doc.content[0].content.as_ref().unwrap();
14105        let cell = &rows[0].content.as_ref().unwrap()[0];
14106        // Cell should have block content (paragraph + bullet list)
14107        let content = cell.content.as_ref().unwrap();
14108        assert!(content.len() >= 2);
14109        assert_eq!(content[1].node_type, "bulletList");
14110    }
14111
14112    #[test]
14113    fn directive_table_with_cell_attrs() {
14114        let md = "::::table\n:::tr\n:::td{colspan=2 bg=#DEEBFF}\nSpanning cell\n:::\n:::\n::::\n";
14115        let doc = markdown_to_adf(md).unwrap();
14116        let cell = &doc.content[0].content.as_ref().unwrap()[0]
14117            .content
14118            .as_ref()
14119            .unwrap()[0];
14120        let attrs = cell.attrs.as_ref().unwrap();
14121        assert_eq!(attrs["colspan"], 2);
14122        assert_eq!(attrs["background"], "#DEEBFF");
14123    }
14124
14125    #[test]
14126    fn directive_table_with_css_var_background() {
14127        let bg = "var(--ds-background-accent-gray-subtlest, var(--ds-background-accent-gray-subtlest, #F1F2F4))";
14128        let md = format!("::::table\n:::tr\n:::th{{bg=\"{bg}\"}}\nHeader\n:::\n:::\n::::\n");
14129        let doc = markdown_to_adf(&md).unwrap();
14130        let row = &doc.content[0].content.as_ref().unwrap()[0];
14131        let cells = row.content.as_ref().unwrap();
14132        assert_eq!(cells.len(), 1, "row must have at least one cell");
14133        let attrs = cells[0].attrs.as_ref().unwrap();
14134        assert_eq!(attrs["background"], bg);
14135    }
14136
14137    #[test]
14138    fn css_var_background_round_trips() {
14139        let bg = "var(--ds-background-accent-gray-subtlest, #F1F2F4)";
14140        let adf = AdfDocument {
14141            version: 1,
14142            doc_type: "doc".to_string(),
14143            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
14144                AdfNode::table_header_with_attrs(
14145                    vec![AdfNode::paragraph(vec![AdfNode::text("Header")])],
14146                    serde_json::json!({"background": bg}),
14147                ),
14148            ])])],
14149        };
14150        let md = adf_to_markdown(&adf).unwrap();
14151        assert!(
14152            md.contains(&format!("bg=\"{bg}\"")),
14153            "bg value must be quoted in markdown: {md}"
14154        );
14155
14156        let round_tripped = markdown_to_adf(&md).unwrap();
14157        let row = &round_tripped.content[0].content.as_ref().unwrap()[0];
14158        let cells = row.content.as_ref().unwrap();
14159        assert_eq!(cells.len(), 1, "round-tripped row must have one cell");
14160        let rt_attrs = cells[0].attrs.as_ref().unwrap();
14161        assert_eq!(rt_attrs["background"], bg);
14162    }
14163
14164    #[test]
14165    fn directive_table_with_table_attrs() {
14166        let md = "::::table{layout=wide numbered}\n:::tr\n:::td\nCell\n:::\n:::\n::::\n";
14167        let doc = markdown_to_adf(md).unwrap();
14168        let attrs = doc.content[0].attrs.as_ref().unwrap();
14169        assert_eq!(attrs["layout"], "wide");
14170        assert_eq!(attrs["isNumberColumnEnabled"], true);
14171    }
14172
14173    #[test]
14174    fn adf_table_with_block_content_renders_directive_form() {
14175        // Table with a bullet list in a cell → should render as ::::table directive
14176        let doc = AdfDocument {
14177            version: 1,
14178            doc_type: "doc".to_string(),
14179            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
14180                AdfNode::table_cell(vec![
14181                    AdfNode::paragraph(vec![AdfNode::text("Cell with list:")]),
14182                    AdfNode::bullet_list(vec![AdfNode::list_item(vec![AdfNode::paragraph(vec![
14183                        AdfNode::text("Item 1"),
14184                    ])])]),
14185                ]),
14186            ])])],
14187        };
14188        let md = adf_to_markdown(&doc).unwrap();
14189        assert!(md.contains("::::table"));
14190        assert!(md.contains(":::td"));
14191        assert!(md.contains("- Item 1"));
14192    }
14193
14194    #[test]
14195    fn adf_table_inline_only_renders_pipe_form() {
14196        // Table with only inline content → pipe table
14197        let doc = AdfDocument {
14198            version: 1,
14199            doc_type: "doc".to_string(),
14200            content: vec![AdfNode::table(vec![
14201                AdfNode::table_row(vec![
14202                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
14203                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
14204                ]),
14205                AdfNode::table_row(vec![
14206                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C1")])]),
14207                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
14208                ]),
14209            ])],
14210        };
14211        let md = adf_to_markdown(&doc).unwrap();
14212        assert!(md.contains("| H1 | H2 |"));
14213        assert!(!md.contains("::::table"));
14214    }
14215
14216    #[test]
14217    fn adf_table_header_outside_first_row_renders_directive() {
14218        let doc = AdfDocument {
14219            version: 1,
14220            doc_type: "doc".to_string(),
14221            content: vec![AdfNode::table(vec![
14222                AdfNode::table_row(vec![
14223                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H")])]),
14224                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C")])]),
14225                ]),
14226                AdfNode::table_row(vec![
14227                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
14228                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
14229                ]),
14230            ])],
14231        };
14232        let md = adf_to_markdown(&doc).unwrap();
14233        assert!(md.contains("::::table"));
14234        assert!(md.contains(":::th"));
14235    }
14236
14237    #[test]
14238    fn adf_table_cell_attrs_rendered() {
14239        let doc = AdfDocument {
14240            version: 1,
14241            doc_type: "doc".to_string(),
14242            content: vec![AdfNode::table(vec![
14243                AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
14244                    AdfNode::text("H"),
14245                ])])]),
14246                AdfNode::table_row(vec![AdfNode::table_cell_with_attrs(
14247                    vec![AdfNode::paragraph(vec![AdfNode::text("C")])],
14248                    serde_json::json!({"background": "#DEEBFF", "colspan": 2}),
14249                )]),
14250            ])],
14251        };
14252        let md = adf_to_markdown(&doc).unwrap();
14253        assert!(md.contains("{colspan=2 bg=#DEEBFF}"));
14254    }
14255
14256    // ── Pipe table cell attrs tests ────────────────────────────────
14257
14258    #[test]
14259    fn pipe_table_cell_attrs() {
14260        let md = "| H1 | H2 |\n|---|---|\n| {bg=#DEEBFF} highlighted | normal |\n";
14261        let doc = markdown_to_adf(md).unwrap();
14262        let rows = doc.content[0].content.as_ref().unwrap();
14263        let cell = &rows[1].content.as_ref().unwrap()[0];
14264        let attrs = cell.attrs.as_ref().unwrap();
14265        assert_eq!(attrs["background"], "#DEEBFF");
14266    }
14267
14268    #[test]
14269    fn pipe_table_cell_colspan() {
14270        let md = "| H1 | H2 |\n|---|---|\n| {colspan=2} spanning |\n";
14271        let doc = markdown_to_adf(md).unwrap();
14272        let rows = doc.content[0].content.as_ref().unwrap();
14273        let cell = &rows[1].content.as_ref().unwrap()[0];
14274        let attrs = cell.attrs.as_ref().unwrap();
14275        assert_eq!(attrs["colspan"], 2);
14276    }
14277
14278    #[test]
14279    fn trailing_space_after_mention_in_table_cell_preserved() {
14280        // Issue #372: trailing space after mention in table cell was dropped
14281        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":[
14282          {"type":"mention","attrs":{"id":"aaa","text":"@Rob"}},
14283          {"type":"text","text":" "}
14284        ]}]}]}]}]}"#;
14285        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14286        let md = adf_to_markdown(&doc).unwrap();
14287        let round_tripped = markdown_to_adf(&md).unwrap();
14288        let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14289            .content
14290            .as_ref()
14291            .unwrap()[0];
14292        let para = &cell.content.as_ref().unwrap()[0];
14293        let inlines = para.content.as_ref().unwrap();
14294        assert!(
14295            inlines.len() >= 2,
14296            "expected mention + text(' ') nodes, got {} nodes: {:?}",
14297            inlines.len(),
14298            inlines.iter().map(|n| &n.node_type).collect::<Vec<_>>()
14299        );
14300        assert_eq!(inlines[0].node_type, "mention");
14301        assert_eq!(inlines[1].node_type, "text");
14302        assert_eq!(inlines[1].text.as_deref(), Some(" "));
14303    }
14304
14305    // ── Column alignment tests ─────────────────────────────────────
14306
14307    #[test]
14308    fn pipe_table_column_alignment() {
14309        let md = "| Left | Center | Right |\n|:---|:---:|---:|\n| L | C | R |\n";
14310        let doc = markdown_to_adf(md).unwrap();
14311        let rows = doc.content[0].content.as_ref().unwrap();
14312        // Header row
14313        let h_cells = rows[0].content.as_ref().unwrap();
14314        // Left → no mark
14315        assert!(h_cells[0].content.as_ref().unwrap()[0].marks.is_none());
14316        // Center → alignment center
14317        let center_marks = h_cells[1].content.as_ref().unwrap()[0]
14318            .marks
14319            .as_ref()
14320            .unwrap();
14321        assert_eq!(center_marks[0].attrs.as_ref().unwrap()["align"], "center");
14322        // Right → alignment end
14323        let right_marks = h_cells[2].content.as_ref().unwrap()[0]
14324            .marks
14325            .as_ref()
14326            .unwrap();
14327        assert_eq!(right_marks[0].attrs.as_ref().unwrap()["align"], "end");
14328    }
14329
14330    #[test]
14331    fn adf_table_alignment_roundtrip() {
14332        let doc = AdfDocument {
14333            version: 1,
14334            doc_type: "doc".to_string(),
14335            content: vec![AdfNode::table(vec![
14336                AdfNode::table_row(vec![
14337                    AdfNode::table_header(vec![{
14338                        let mut p = AdfNode::paragraph(vec![AdfNode::text("Center")]);
14339                        p.marks = Some(vec![AdfMark::alignment("center")]);
14340                        p
14341                    }]),
14342                    AdfNode::table_header(vec![{
14343                        let mut p = AdfNode::paragraph(vec![AdfNode::text("Right")]);
14344                        p.marks = Some(vec![AdfMark::alignment("end")]);
14345                        p
14346                    }]),
14347                ]),
14348                AdfNode::table_row(vec![
14349                    AdfNode::table_cell(vec![{
14350                        let mut p = AdfNode::paragraph(vec![AdfNode::text("C")]);
14351                        p.marks = Some(vec![AdfMark::alignment("center")]);
14352                        p
14353                    }]),
14354                    AdfNode::table_cell(vec![{
14355                        let mut p = AdfNode::paragraph(vec![AdfNode::text("R")]);
14356                        p.marks = Some(vec![AdfMark::alignment("end")]);
14357                        p
14358                    }]),
14359                ]),
14360            ])],
14361        };
14362        let md = adf_to_markdown(&doc).unwrap();
14363        assert!(md.contains(":---:"));
14364        assert!(md.contains("---:"));
14365    }
14366
14367    // ── Panel custom attrs tests ───────────────────────────────────
14368
14369    #[test]
14370    fn panel_custom_attrs_round_trip() {
14371        let md = ":::panel{type=custom icon=\":star:\" color=\"#DEEBFF\"}\nContent\n:::\n";
14372        let doc = markdown_to_adf(md).unwrap();
14373        let panel = &doc.content[0];
14374        let attrs = panel.attrs.as_ref().unwrap();
14375        assert_eq!(attrs["panelType"], "custom");
14376        assert_eq!(attrs["panelIcon"], ":star:");
14377        assert_eq!(attrs["panelColor"], "#DEEBFF");
14378
14379        let result = adf_to_markdown(&doc).unwrap();
14380        assert!(result.contains("type=custom"));
14381        assert!(result.contains("icon="));
14382        assert!(result.contains("color="));
14383    }
14384
14385    // ── Block card with attrs tests ────────────────────────────────
14386
14387    #[test]
14388    fn block_card_with_layout() {
14389        let md = "::card[https://example.com]{layout=wide}\n";
14390        let doc = markdown_to_adf(md).unwrap();
14391        let attrs = doc.content[0].attrs.as_ref().unwrap();
14392        assert_eq!(attrs["layout"], "wide");
14393
14394        let result = adf_to_markdown(&doc).unwrap();
14395        assert!(result.contains("::card[https://example.com]{layout=wide}"));
14396    }
14397
14398    // ── Extension params test ──────────────────────────────────────
14399
14400    #[test]
14401    fn extension_with_params() {
14402        let md = r#"::extension{type=com.atlassian.macro key=jira-chart params='{"jql":"project=PROJ"}'}"#;
14403        let doc = markdown_to_adf(&format!("{md}\n")).unwrap();
14404        let attrs = doc.content[0].attrs.as_ref().unwrap();
14405        assert_eq!(attrs["parameters"]["jql"], "project=PROJ");
14406    }
14407
14408    #[test]
14409    fn leaf_extension_layout_preserved_in_roundtrip() {
14410        // Issue #381: layout attr on extension nodes was dropped
14411        let adf_json = r#"{"version":1,"type":"doc","content":[
14412          {"type":"extension","attrs":{"extensionType":"com.atlassian.confluence.macro.core","extensionKey":"toc","layout":"default","parameters":{}}}
14413        ]}"#;
14414        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14415        let md = adf_to_markdown(&doc).unwrap();
14416        assert!(
14417            md.contains("layout=default"),
14418            "JFM should contain layout=default, got: {md}"
14419        );
14420        let round_tripped = markdown_to_adf(&md).unwrap();
14421        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14422        assert_eq!(attrs["layout"], "default", "layout should be preserved");
14423        assert_eq!(attrs["extensionKey"], "toc");
14424    }
14425
14426    #[test]
14427    fn bodied_extension_layout_preserved_in_roundtrip() {
14428        // Bodied extension with layout
14429        let adf_json = r#"{"version":1,"type":"doc","content":[
14430          {"type":"bodiedExtension","attrs":{"extensionType":"com.atlassian.macro","extensionKey":"expand","layout":"wide"},
14431           "content":[{"type":"paragraph","content":[{"type":"text","text":"inner"}]}]}
14432        ]}"#;
14433        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14434        let md = adf_to_markdown(&doc).unwrap();
14435        assert!(
14436            md.contains("layout=wide"),
14437            "JFM should contain layout=wide, got: {md}"
14438        );
14439        let round_tripped = markdown_to_adf(&md).unwrap();
14440        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14441        assert_eq!(attrs["layout"], "wide", "layout should be preserved");
14442    }
14443
14444    #[test]
14445    fn bodied_extension_parameters_preserved_in_roundtrip() {
14446        // Issue #473: parameters block inside bodiedExtension.attrs was dropped
14447        let adf_json = r#"{"version":1,"type":"doc","content":[
14448          {"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":{}}},
14449           "content":[{"type":"paragraph","content":[{"type":"text","text":"Content inside bodied extension"}]}]}
14450        ]}"#;
14451        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14452        let md = adf_to_markdown(&doc).unwrap();
14453        assert!(
14454            md.contains("params="),
14455            "JFM should contain params attribute, got: {md}"
14456        );
14457        let round_tripped = markdown_to_adf(&md).unwrap();
14458        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14459        assert_eq!(
14460            attrs["parameters"]["macroMetadata"]["title"], "Page Properties",
14461            "parameters should be preserved in round-trip"
14462        );
14463        assert_eq!(attrs["extensionKey"], "details");
14464        assert_eq!(attrs["layout"], "default");
14465        assert_eq!(attrs["localId"], "aabbccdd-1234");
14466    }
14467
14468    #[test]
14469    fn bodied_extension_malformed_params_ignored() {
14470        // Malformed params JSON should be silently ignored, not crash
14471        let md = ":::extension{type=com.atlassian.macro key=details params='not-valid-json'}\nContent\n:::\n";
14472        let doc = markdown_to_adf(md).unwrap();
14473        let attrs = doc.content[0].attrs.as_ref().unwrap();
14474        assert_eq!(attrs["extensionKey"], "details");
14475        // parameters should be absent since the JSON was invalid
14476        assert!(attrs.get("parameters").is_none());
14477    }
14478
14479    #[test]
14480    fn leaf_extension_localid_preserved_in_roundtrip() {
14481        // Extension with both layout and localId
14482        let adf_json = r#"{"version":1,"type":"doc","content":[
14483          {"type":"extension","attrs":{"extensionType":"com.atlassian.macro","extensionKey":"toc","layout":"default","localId":"abc-123"}}
14484        ]}"#;
14485        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14486        let md = adf_to_markdown(&doc).unwrap();
14487        let round_tripped = markdown_to_adf(&md).unwrap();
14488        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14489        assert_eq!(attrs["layout"], "default");
14490        assert_eq!(attrs["localId"], "abc-123");
14491    }
14492
14493    // ── Mention with userType test ─────────────────────────────────
14494
14495    #[test]
14496    fn mention_with_user_type() {
14497        let md = "Hi :mention[Alice]{id=abc123 userType=DEFAULT}.\n";
14498        let doc = markdown_to_adf(md).unwrap();
14499        let mention = &doc.content[0].content.as_ref().unwrap()[1];
14500        assert_eq!(mention.attrs.as_ref().unwrap()["userType"], "DEFAULT");
14501
14502        let result = adf_to_markdown(&doc).unwrap();
14503        assert!(result.contains("userType=DEFAULT"));
14504    }
14505
14506    // ── Colwidth tests ─────────────────────────────────────────────
14507
14508    #[test]
14509    fn directive_table_colwidth() {
14510        let md = "::::table\n:::tr\n:::td{colwidth=100,200}\nCell\n:::\n:::\n::::\n";
14511        let doc = markdown_to_adf(md).unwrap();
14512        let cell = &doc.content[0].content.as_ref().unwrap()[0]
14513            .content
14514            .as_ref()
14515            .unwrap()[0];
14516        let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
14517        assert_eq!(colwidth, &[serde_json::json!(100), serde_json::json!(200)]);
14518    }
14519
14520    #[test]
14521    fn directive_table_colwidth_float_roundtrip() {
14522        // Confluence returns colwidth as floats (e.g. 157.0, 863.0).
14523        // adf_to_markdown must preserve them so markdown_to_adf can restore them.
14524        let adf_doc = serde_json::json!({
14525            "type": "doc",
14526            "version": 1,
14527            "content": [{
14528                "type": "table",
14529                "content": [{
14530                    "type": "tableRow",
14531                    "content": [
14532                        {
14533                            "type": "tableHeader",
14534                            "attrs": { "colwidth": [157.0] },
14535                            "content": [{ "type": "paragraph" }]
14536                        },
14537                        {
14538                            "type": "tableHeader",
14539                            "attrs": { "colwidth": [863.0] },
14540                            "content": [{ "type": "paragraph" }]
14541                        }
14542                    ]
14543                }]
14544            }]
14545        });
14546        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
14547        let md = adf_to_markdown(&doc).unwrap();
14548        assert!(
14549            md.contains("colwidth=157.0"),
14550            "expected colwidth=157.0 in markdown, got: {md}"
14551        );
14552        assert!(
14553            md.contains("colwidth=863.0"),
14554            "expected colwidth=863.0 in markdown, got: {md}"
14555        );
14556        // Round-trip back to ADF
14557        let doc2 = markdown_to_adf(&md).unwrap();
14558        let row = &doc2.content[0].content.as_ref().unwrap()[0];
14559        let header1 = &row.content.as_ref().unwrap()[0];
14560        let header2 = &row.content.as_ref().unwrap()[1];
14561        assert_eq!(
14562            header1.attrs.as_ref().unwrap()["colwidth"]
14563                .as_array()
14564                .unwrap(),
14565            &[serde_json::json!(157.0)]
14566        );
14567        assert_eq!(
14568            header2.attrs.as_ref().unwrap()["colwidth"]
14569                .as_array()
14570                .unwrap(),
14571            &[serde_json::json!(863.0)]
14572        );
14573    }
14574
14575    #[test]
14576    fn colwidth_float_preserved_in_roundtrip() {
14577        // Issue #369: colwidth 254.0 was coerced to integer 254
14578        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":[]}]}]}]}]}"#;
14579        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14580        let md = adf_to_markdown(&doc).unwrap();
14581        let round_tripped = markdown_to_adf(&md).unwrap();
14582        let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14583            .content
14584            .as_ref()
14585            .unwrap()[0];
14586        let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
14587        assert_eq!(
14588            colwidth,
14589            &[serde_json::json!(254.0), serde_json::json!(416.0)],
14590            "colwidth should preserve float values"
14591        );
14592    }
14593
14594    #[test]
14595    fn colwidth_integer_preserved_in_roundtrip() {
14596        // Issue #459: colwidth integer values emitted as floats after round-trip
14597        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"}]}]}]}]}]}"#;
14598        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14599        let md = adf_to_markdown(&doc).unwrap();
14600        assert!(
14601            md.contains("colwidth=150"),
14602            "expected colwidth=150 (no decimal) in markdown, got: {md}"
14603        );
14604        assert!(
14605            !md.contains("colwidth=150.0"),
14606            "colwidth should not have .0 suffix for integers, got: {md}"
14607        );
14608        // Round-trip back to ADF
14609        let round_tripped = markdown_to_adf(&md).unwrap();
14610        let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14611            .content
14612            .as_ref()
14613            .unwrap()[0];
14614        let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
14615        assert_eq!(
14616            colwidth,
14617            &[serde_json::json!(150)],
14618            "colwidth should preserve integer values"
14619        );
14620        // Verify JSON serialization uses integer, not float
14621        let json_output = serde_json::to_string(&round_tripped).unwrap();
14622        assert!(
14623            json_output.contains(r#""colwidth":[150]"#),
14624            "JSON should contain integer colwidth, got: {json_output}"
14625        );
14626    }
14627
14628    #[test]
14629    fn colwidth_mixed_int_and_float_roundtrip() {
14630        // Integer colwidth from standard ADF and float colwidth from Confluence
14631        // should each preserve their original type through round-trip.
14632        let int_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colwidth":[100,200]}}]}]}]}"#;
14633        let float_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colwidth":[100.0,200.0]}}]}]}]}"#;
14634
14635        // Integer input → integer output
14636        let int_doc: AdfDocument = serde_json::from_str(int_json).unwrap();
14637        let int_md = adf_to_markdown(&int_doc).unwrap();
14638        assert!(
14639            int_md.contains("colwidth=100,200"),
14640            "integer colwidth in md: {int_md}"
14641        );
14642        let int_rt = markdown_to_adf(&int_md).unwrap();
14643        let int_serial = serde_json::to_string(&int_rt).unwrap();
14644        assert!(
14645            int_serial.contains(r#""colwidth":[100,200]"#),
14646            "integer colwidth in JSON: {int_serial}"
14647        );
14648
14649        // Float input → float output
14650        let float_doc: AdfDocument = serde_json::from_str(float_json).unwrap();
14651        let float_md = adf_to_markdown(&float_doc).unwrap();
14652        assert!(
14653            float_md.contains("colwidth=100.0,200.0"),
14654            "float colwidth in md: {float_md}"
14655        );
14656        let float_rt = markdown_to_adf(&float_md).unwrap();
14657        let float_serial = serde_json::to_string(&float_rt).unwrap();
14658        assert!(
14659            float_serial.contains(r#""colwidth":[100.0,200.0]"#),
14660            "float colwidth in JSON: {float_serial}"
14661        );
14662    }
14663
14664    #[test]
14665    fn colwidth_fractional_float_preserved() {
14666        // Covers the fractional-float branch (n.fract() != 0.0) in build_cell_attrs_string
14667        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"}]}]}]}]}]}"#;
14668        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14669        let md = adf_to_markdown(&doc).unwrap();
14670        assert!(
14671            md.contains("colwidth=100.5"),
14672            "expected colwidth=100.5 in markdown, got: {md}"
14673        );
14674    }
14675
14676    #[test]
14677    fn colwidth_non_numeric_values_skipped() {
14678        // Covers the None branch for non-numeric colwidth entries in build_cell_attrs_string
14679        let adf_doc = serde_json::json!({
14680            "type": "doc",
14681            "version": 1,
14682            "content": [{
14683                "type": "table",
14684                "content": [{
14685                    "type": "tableRow",
14686                    "content": [{
14687                        "type": "tableCell",
14688                        "attrs": { "colwidth": ["invalid"] },
14689                        "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "cell" }] }]
14690                    }]
14691                }]
14692            }]
14693        });
14694        let doc: AdfDocument = serde_json::from_value(adf_doc).unwrap();
14695        let md = adf_to_markdown(&doc).unwrap();
14696        // Non-numeric values are filtered out, so colwidth should not appear
14697        assert!(
14698            !md.contains("colwidth"),
14699            "non-numeric colwidth should be filtered out, got: {md}"
14700        );
14701    }
14702
14703    #[test]
14704    fn default_rowspan_colspan_preserved_in_roundtrip() {
14705        // Issue #369: rowspan=1 and colspan=1 were elided
14706        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"}]}]}]}]}]}"#;
14707        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14708        let md = adf_to_markdown(&doc).unwrap();
14709        let round_tripped = markdown_to_adf(&md).unwrap();
14710        let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14711            .content
14712            .as_ref()
14713            .unwrap()[0];
14714        let attrs = cell.attrs.as_ref().unwrap();
14715        assert_eq!(attrs["rowspan"], 1, "rowspan=1 should be preserved");
14716        assert_eq!(attrs["colspan"], 1, "colspan=1 should be preserved");
14717    }
14718
14719    // ── Nested list tests ──────────────────────────────────────────────
14720
14721    #[test]
14722    fn table_localid_preserved_in_roundtrip() {
14723        // Issue #374: localId on table nodes was dropped
14724        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"}]}]}]}]}]}"#;
14725        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14726        let md = adf_to_markdown(&doc).unwrap();
14727        assert!(
14728            md.contains("localId="),
14729            "JFM should contain localId, got: {md}"
14730        );
14731        let round_tripped = markdown_to_adf(&md).unwrap();
14732        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14733        assert_eq!(
14734            attrs["localId"], "7afd4550-e66c-4b12-875f-a91c6c7b62c7",
14735            "localId should be preserved"
14736        );
14737    }
14738
14739    #[test]
14740    fn paragraph_localid_preserved_in_roundtrip() {
14741        // Issue #399: localId on paragraph nodes was dropped
14742        let adf_json = r#"{"version":1,"type":"doc","content":[
14743          {"type":"paragraph","attrs":{"localId":"abc-123"},"content":[{"type":"text","text":"hello"}]}
14744        ]}"#;
14745        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14746        let md = adf_to_markdown(&doc).unwrap();
14747        assert!(
14748            md.contains("localId=abc-123"),
14749            "JFM should contain localId, got: {md}"
14750        );
14751        let round_tripped = markdown_to_adf(&md).unwrap();
14752        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14753        assert_eq!(attrs["localId"], "abc-123", "localId should be preserved");
14754    }
14755
14756    #[test]
14757    fn heading_localid_preserved_in_roundtrip() {
14758        let adf_json = r#"{"version":1,"type":"doc","content":[
14759          {"type":"heading","attrs":{"level":2,"localId":"h-456"},"content":[{"type":"text","text":"Title"}]}
14760        ]}"#;
14761        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14762        let md = adf_to_markdown(&doc).unwrap();
14763        let round_tripped = markdown_to_adf(&md).unwrap();
14764        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14765        assert_eq!(attrs["localId"], "h-456");
14766    }
14767
14768    #[test]
14769    fn localid_with_alignment_preserved() {
14770        // localId and alignment marks should coexist in the same {attrs} block
14771        let adf_json = r#"{"version":1,"type":"doc","content":[
14772          {"type":"paragraph","attrs":{"localId":"p-789"},"marks":[{"type":"alignment","attrs":{"align":"center"}}],
14773           "content":[{"type":"text","text":"centered"}]}
14774        ]}"#;
14775        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14776        let md = adf_to_markdown(&doc).unwrap();
14777        assert!(md.contains("localId=p-789"), "should have localId: {md}");
14778        assert!(md.contains("align=center"), "should have align: {md}");
14779        let round_tripped = markdown_to_adf(&md).unwrap();
14780        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14781        assert_eq!(attrs["localId"], "p-789");
14782        let marks = round_tripped.content[0].marks.as_ref().unwrap();
14783        assert!(marks.iter().any(|m| m.mark_type == "alignment"));
14784    }
14785
14786    #[test]
14787    fn table_layout_default_preserved_in_roundtrip() {
14788        // Issue #380: layout='default' 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["layout"], "default",
14796            "layout='default' should be preserved"
14797        );
14798    }
14799
14800    #[test]
14801    fn table_is_number_column_enabled_false_preserved() {
14802        // Issue #380: isNumberColumnEnabled=false was elided
14803        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"}]}]}]}]}]}"#;
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"], false,
14810            "isNumberColumnEnabled=false should be preserved"
14811        );
14812    }
14813
14814    #[test]
14815    fn table_is_number_column_enabled_true_preserved() {
14816        // Regression check: isNumberColumnEnabled=true should still work
14817        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"}]}]}]}]}]}"#;
14818        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14819        let md = adf_to_markdown(&doc).unwrap();
14820        let round_tripped = markdown_to_adf(&md).unwrap();
14821        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14822        assert_eq!(
14823            attrs["isNumberColumnEnabled"], true,
14824            "isNumberColumnEnabled=true should be preserved"
14825        );
14826    }
14827
14828    #[test]
14829    fn directive_table_is_number_column_enabled_false_preserved() {
14830        // Covers render_directive_table + directive table parsing for numbered=false.
14831        // Multi-paragraph cell forces directive table form.
14832        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[
14833          {"type":"paragraph","content":[{"type":"text","text":"line one"}]},
14834          {"type":"paragraph","content":[{"type":"text","text":"line two"}]}
14835        ]}]}]}]}"#;
14836        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14837        let md = adf_to_markdown(&doc).unwrap();
14838        assert!(md.contains("::::table"), "should use directive table form");
14839        assert!(
14840            md.contains("numbered=false"),
14841            "should contain numbered=false, got: {md}"
14842        );
14843        let round_tripped = markdown_to_adf(&md).unwrap();
14844        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14845        assert_eq!(attrs["isNumberColumnEnabled"], false);
14846        assert_eq!(attrs["layout"], "default");
14847    }
14848
14849    #[test]
14850    fn directive_table_is_number_column_enabled_true_preserved() {
14851        // Covers render_directive_table + directive table parsing for numbered (true).
14852        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":true,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[
14853          {"type":"paragraph","content":[{"type":"text","text":"line one"}]},
14854          {"type":"paragraph","content":[{"type":"text","text":"line two"}]}
14855        ]}]}]}]}"#;
14856        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14857        let md = adf_to_markdown(&doc).unwrap();
14858        assert!(md.contains("::::table"), "should use directive table form");
14859        assert!(
14860            md.contains("numbered}") || md.contains("numbered "),
14861            "should contain numbered flag, got: {md}"
14862        );
14863        let round_tripped = markdown_to_adf(&md).unwrap();
14864        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14865        assert_eq!(attrs["isNumberColumnEnabled"], true);
14866    }
14867
14868    #[test]
14869    fn trailing_space_in_bullet_list_item_preserved() {
14870        // Issue #394: trailing space text node in list item dropped
14871        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
14872          {"type":"listItem","content":[{"type":"paragraph","content":[
14873            {"type":"text","text":"Before link "},
14874            {"type":"text","text":"link text","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
14875            {"type":"text","text":" "}
14876          ]}]}
14877        ]}]}"#;
14878        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14879        let md = adf_to_markdown(&doc).unwrap();
14880        let round_tripped = markdown_to_adf(&md).unwrap();
14881        let list = &round_tripped.content[0];
14882        let item = &list.content.as_ref().unwrap()[0];
14883        let para = &item.content.as_ref().unwrap()[0];
14884        let inlines = para.content.as_ref().unwrap();
14885        let last = inlines.last().unwrap();
14886        assert_eq!(
14887            last.text.as_deref(),
14888            Some(" "),
14889            "trailing space text node should be preserved, got nodes: {:?}",
14890            inlines
14891                .iter()
14892                .map(|n| (&n.node_type, &n.text))
14893                .collect::<Vec<_>>()
14894        );
14895    }
14896
14897    #[test]
14898    fn trailing_space_after_mention_in_bullet_list_preserved() {
14899        // Mention + trailing space in list item
14900        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
14901          {"type":"listItem","content":[{"type":"paragraph","content":[
14902            {"type":"mention","attrs":{"id":"abc","text":"@Alice"}},
14903            {"type":"text","text":" "}
14904          ]}]}
14905        ]}]}"#;
14906        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14907        let md = adf_to_markdown(&doc).unwrap();
14908        let round_tripped = markdown_to_adf(&md).unwrap();
14909        let para = &round_tripped.content[0].content.as_ref().unwrap()[0]
14910            .content
14911            .as_ref()
14912            .unwrap()[0];
14913        let inlines = para.content.as_ref().unwrap();
14914        assert!(
14915            inlines.len() >= 2,
14916            "should have mention + trailing space, got {} nodes",
14917            inlines.len()
14918        );
14919        assert_eq!(inlines.last().unwrap().text.as_deref(), Some(" "));
14920    }
14921
14922    #[test]
14923    fn trailing_space_in_ordered_list_item_preserved() {
14924        // Same issue in ordered list context
14925        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
14926          {"type":"listItem","content":[{"type":"paragraph","content":[
14927            {"type":"text","text":"item "},
14928            {"type":"text","text":"link","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
14929            {"type":"text","text":" "}
14930          ]}]}
14931        ]}]}"#;
14932        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14933        let md = adf_to_markdown(&doc).unwrap();
14934        let round_tripped = markdown_to_adf(&md).unwrap();
14935        let para = &round_tripped.content[0].content.as_ref().unwrap()[0]
14936            .content
14937            .as_ref()
14938            .unwrap()[0];
14939        let inlines = para.content.as_ref().unwrap();
14940        let last = inlines.last().unwrap();
14941        assert_eq!(
14942            last.text.as_deref(),
14943            Some(" "),
14944            "trailing space should be preserved in ordered list item"
14945        );
14946    }
14947
14948    #[test]
14949    fn trailing_space_in_heading_text_preserved() {
14950        // Issue #400: trailing space in heading text node trimmed on round-trip
14951        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[
14952          {"type":"text","text":"Firefighting Engineers "}
14953        ]}]}"#;
14954        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14955        let md = adf_to_markdown(&doc).unwrap();
14956        let round_tripped = markdown_to_adf(&md).unwrap();
14957        let inlines = round_tripped.content[0].content.as_ref().unwrap();
14958        assert_eq!(
14959            inlines[0].text.as_deref(),
14960            Some("Firefighting Engineers "),
14961            "trailing space in heading should be preserved"
14962        );
14963    }
14964
14965    #[test]
14966    fn trailing_space_in_heading_before_bold_preserved() {
14967        // Issue #400: trailing space before bold sibling in heading
14968        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[
14969          {"type":"text","text":"Classic "},
14970          {"type":"text","text":"bold","marks":[{"type":"strong"}]}
14971        ]}]}"#;
14972        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14973        let md = adf_to_markdown(&doc).unwrap();
14974        let round_tripped = markdown_to_adf(&md).unwrap();
14975        let inlines = round_tripped.content[0].content.as_ref().unwrap();
14976        assert_eq!(
14977            inlines[0].text.as_deref(),
14978            Some("Classic "),
14979            "trailing space in heading text before bold should be preserved"
14980        );
14981    }
14982
14983    #[test]
14984    fn leading_space_in_heading_text_preserved() {
14985        // Issue #492: leading spaces in heading text node stripped on round-trip
14986        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":3},"content":[
14987          {"type":"text","text":"  #general-channel"}
14988        ]}]}"#;
14989        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14990        let md = adf_to_markdown(&doc).unwrap();
14991        let round_tripped = markdown_to_adf(&md).unwrap();
14992        let inlines = round_tripped.content[0].content.as_ref().unwrap();
14993        assert_eq!(
14994            inlines[0].text.as_deref(),
14995            Some("  #general-channel"),
14996            "leading spaces in heading text should be preserved"
14997        );
14998    }
14999
15000    #[test]
15001    fn leading_space_in_heading_before_bold_preserved() {
15002        // Issue #492: leading space before bold sibling in heading
15003        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[
15004          {"type":"text","text":"   indented"},
15005          {"type":"text","text":" bold","marks":[{"type":"strong"}]}
15006        ]}]}"#;
15007        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15008        let md = adf_to_markdown(&doc).unwrap();
15009        let round_tripped = markdown_to_adf(&md).unwrap();
15010        let inlines = round_tripped.content[0].content.as_ref().unwrap();
15011        assert_eq!(
15012            inlines[0].text.as_deref(),
15013            Some("   indented"),
15014            "leading spaces in heading text before bold should be preserved"
15015        );
15016    }
15017
15018    #[test]
15019    fn heading_multiple_leading_spaces_markdown_parse() {
15020        // Issue #492: verify JFM parsing preserves leading spaces
15021        let md = "### \t  #general-channel";
15022        let doc = markdown_to_adf(md).unwrap();
15023        let inlines = doc.content[0].content.as_ref().unwrap();
15024        assert_eq!(
15025            inlines[0].text.as_deref(),
15026            Some("\t  #general-channel"),
15027            "leading whitespace in heading text should be preserved during JFM parsing"
15028        );
15029    }
15030
15031    #[test]
15032    fn trailing_space_in_paragraph_text_preserved() {
15033        // Issue #400: trailing space in paragraph text node preserved on round-trip
15034        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
15035          {"type":"text","text":"word followed by space "},
15036          {"type":"text","text":"next node","marks":[{"type":"strong"}]}
15037        ]}]}"#;
15038        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15039        let md = adf_to_markdown(&doc).unwrap();
15040        let round_tripped = markdown_to_adf(&md).unwrap();
15041        let inlines = round_tripped.content[0].content.as_ref().unwrap();
15042        assert_eq!(
15043            inlines[0].text.as_deref(),
15044            Some("word followed by space "),
15045            "trailing space in paragraph text should be preserved"
15046        );
15047    }
15048
15049    #[test]
15050    fn nested_bullet_list_roundtrip() {
15051        // ADF with a listItem containing a paragraph + nested bulletList
15052        let adf_doc = serde_json::json!({
15053            "type": "doc",
15054            "version": 1,
15055            "content": [{
15056                "type": "bulletList",
15057                "content": [{
15058                    "type": "listItem",
15059                    "content": [
15060                        {
15061                            "type": "paragraph",
15062                            "content": [{"type": "text", "text": "parent item"}]
15063                        },
15064                        {
15065                            "type": "bulletList",
15066                            "content": [
15067                                {
15068                                    "type": "listItem",
15069                                    "content": [{
15070                                        "type": "paragraph",
15071                                        "content": [{"type": "text", "text": "sub item 1"}]
15072                                    }]
15073                                },
15074                                {
15075                                    "type": "listItem",
15076                                    "content": [{
15077                                        "type": "paragraph",
15078                                        "content": [{"type": "text", "text": "sub item 2"}]
15079                                    }]
15080                                }
15081                            ]
15082                        }
15083                    ]
15084                }]
15085            }]
15086        });
15087        let doc: AdfDocument = serde_json::from_value(adf_doc).unwrap();
15088        let md = adf_to_markdown(&doc).unwrap();
15089        assert!(
15090            md.contains("- parent item\n"),
15091            "expected top-level item in markdown, got: {md}"
15092        );
15093        assert!(
15094            md.contains("  - sub item 1\n"),
15095            "expected indented sub item 1 in markdown, got: {md}"
15096        );
15097        assert!(
15098            md.contains("  - sub item 2\n"),
15099            "expected indented sub item 2 in markdown, got: {md}"
15100        );
15101
15102        // Round-trip back
15103        let doc2 = markdown_to_adf(&md).unwrap();
15104        let list = &doc2.content[0];
15105        assert_eq!(list.node_type, "bulletList");
15106        let item = &list.content.as_ref().unwrap()[0];
15107        assert_eq!(item.node_type, "listItem");
15108        let item_content = item.content.as_ref().unwrap();
15109        assert_eq!(
15110            item_content.len(),
15111            2,
15112            "listItem should have paragraph + nested list"
15113        );
15114        assert_eq!(item_content[0].node_type, "paragraph");
15115        assert_eq!(item_content[1].node_type, "bulletList");
15116        let sub_items = item_content[1].content.as_ref().unwrap();
15117        assert_eq!(sub_items.len(), 2);
15118    }
15119
15120    #[test]
15121    fn nested_bullet_in_table_cell_roundtrip() {
15122        let md = "::::table\n:::tr\n:::td\n- parent\n  - child\n:::\n:::\n::::\n";
15123        let doc = markdown_to_adf(md).unwrap();
15124        let table = &doc.content[0];
15125        let row = &table.content.as_ref().unwrap()[0];
15126        let cell = &row.content.as_ref().unwrap()[0];
15127        let list = &cell.content.as_ref().unwrap()[0];
15128        assert_eq!(list.node_type, "bulletList");
15129        let item = &list.content.as_ref().unwrap()[0];
15130        let item_content = item.content.as_ref().unwrap();
15131        assert_eq!(
15132            item_content.len(),
15133            2,
15134            "listItem should have paragraph + nested list"
15135        );
15136        assert_eq!(item_content[1].node_type, "bulletList");
15137
15138        // Round-trip: adf→md→adf should preserve the nested list
15139        let md2 = adf_to_markdown(&doc).unwrap();
15140        assert!(
15141            md2.contains("  - child"),
15142            "expected indented child in round-tripped markdown, got: {md2}"
15143        );
15144    }
15145
15146    #[test]
15147    fn nested_ordered_list_roundtrip() {
15148        // Issue #389: nested orderedList inside listItem flattened
15149        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
15150          {"type":"listItem","content":[
15151            {"type":"paragraph","content":[{"type":"text","text":"Top level"}]},
15152            {"type":"orderedList","attrs":{"order":1},"content":[
15153              {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Nested 1"}]}]},
15154              {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Nested 2"}]}]}
15155            ]}
15156          ]},
15157          {"type":"listItem","content":[
15158            {"type":"paragraph","content":[{"type":"text","text":"Second top"}]}
15159          ]}
15160        ]}]}"#;
15161        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15162        let md = adf_to_markdown(&doc).unwrap();
15163        let round_tripped = markdown_to_adf(&md).unwrap();
15164
15165        // Outer list should have 2 items
15166        let outer = &round_tripped.content[0];
15167        assert_eq!(outer.node_type, "orderedList");
15168        assert_eq!(
15169            outer.attrs.as_ref().unwrap()["order"],
15170            1,
15171            "explicit order=1 must be preserved via trailing {{order=1}} (issue #547)"
15172        );
15173        let outer_items = outer.content.as_ref().unwrap();
15174        assert_eq!(
15175            outer_items.len(),
15176            2,
15177            "outer list should have 2 items, got {}",
15178            outer_items.len()
15179        );
15180
15181        // First item should have paragraph + nested orderedList
15182        let first_item = &outer_items[0];
15183        let first_content = first_item.content.as_ref().unwrap();
15184        assert_eq!(
15185            first_content.len(),
15186            2,
15187            "first listItem should have paragraph + nested list, got {}",
15188            first_content.len()
15189        );
15190        assert_eq!(first_content[0].node_type, "paragraph");
15191        assert_eq!(first_content[1].node_type, "orderedList");
15192        let nested_items = first_content[1].content.as_ref().unwrap();
15193        assert_eq!(nested_items.len(), 2, "nested list should have 2 items");
15194    }
15195
15196    #[test]
15197    fn nested_ordered_list_markdown_parsing() {
15198        // Direct markdown parsing of nested ordered list
15199        let md = "1. Top level\n  1. Nested 1\n  2. Nested 2\n2. Second top\n";
15200        let doc = markdown_to_adf(md).unwrap();
15201        let outer = &doc.content[0];
15202        assert_eq!(outer.node_type, "orderedList");
15203        let outer_items = outer.content.as_ref().unwrap();
15204        assert_eq!(outer_items.len(), 2, "should have 2 top-level items");
15205
15206        let first_content = outer_items[0].content.as_ref().unwrap();
15207        assert_eq!(
15208            first_content.len(),
15209            2,
15210            "first item should have paragraph + nested list"
15211        );
15212        assert_eq!(first_content[1].node_type, "orderedList");
15213    }
15214
15215    #[test]
15216    fn bullet_list_nested_inside_ordered_list() {
15217        // Mixed nesting: bullet list nested inside ordered list
15218        let md = "1. Ordered item\n  - Bullet child 1\n  - Bullet child 2\n2. Second ordered\n";
15219        let doc = markdown_to_adf(md).unwrap();
15220        let outer = &doc.content[0];
15221        assert_eq!(outer.node_type, "orderedList");
15222        let outer_items = outer.content.as_ref().unwrap();
15223        assert_eq!(outer_items.len(), 2);
15224
15225        let first_content = outer_items[0].content.as_ref().unwrap();
15226        assert_eq!(
15227            first_content.len(),
15228            2,
15229            "first item should have paragraph + nested list"
15230        );
15231        assert_eq!(first_content[1].node_type, "bulletList");
15232        let sub_items = first_content[1].content.as_ref().unwrap();
15233        assert_eq!(sub_items.len(), 2, "nested bullet list should have 2 items");
15234    }
15235
15236    #[test]
15237    fn ordered_list_order_attr_one_is_elided() {
15238        // Issue #547: order=1 is the default and must be elided from attrs
15239        // for round-trip fidelity with ADF documents that omit the attrs
15240        // object on orderedList.
15241        let md = "1. A\n2. B\n";
15242        let doc = markdown_to_adf(md).unwrap();
15243        assert!(
15244            doc.content[0].attrs.is_none(),
15245            "attrs should be elided when order=1"
15246        );
15247
15248        // Round-trip should preserve the elision
15249        let md2 = adf_to_markdown(&doc).unwrap();
15250        let doc2 = markdown_to_adf(&md2).unwrap();
15251        assert!(
15252            doc2.content[0].attrs.is_none(),
15253            "attrs should remain elided after round-trip"
15254        );
15255    }
15256
15257    #[test]
15258    fn issue_547_ordered_list_no_attrs_roundtrip_byte_identical() {
15259        // Issue #547: ADF orderedList without an attrs field must round-trip
15260        // (ADF → JFM → ADF) without gaining a spurious {"order": 1} attrs.
15261        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"}]}]}]}]}"#;
15262        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15263        let md = adf_to_markdown(&doc).unwrap();
15264        let rt = markdown_to_adf(&md).unwrap();
15265        assert!(
15266            rt.content[0].attrs.is_none(),
15267            "round-tripped orderedList should not have attrs, got: {:?}",
15268            rt.content[0].attrs
15269        );
15270
15271        // Serialized JSON must also omit attrs entirely for byte fidelity.
15272        let rt_json = serde_json::to_string(&rt).unwrap();
15273        assert!(
15274            !rt_json.contains("\"order\""),
15275            "round-tripped JSON should not contain \"order\", got: {rt_json}"
15276        );
15277    }
15278
15279    // ── Issue #547: orderedList byte-identical roundtrip coverage ───────
15280
15281    /// Assert that ADF → JFM → ADF produces a document whose serialized JSON
15282    /// (as a sorted-key canonical form) matches the source JSON. Mirrors the
15283    /// `jq --sort-keys` comparison used in the issue's reproducer.
15284    fn assert_roundtrip_byte_identical(adf_json: &str) {
15285        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15286        let md = adf_to_markdown(&doc).unwrap();
15287        let rt = markdown_to_adf(&md).unwrap();
15288
15289        let canonical_src: serde_json::Value = serde_json::from_str(adf_json).unwrap();
15290        let canonical_rt: serde_json::Value =
15291            serde_json::from_str(&serde_json::to_string(&rt).unwrap()).unwrap();
15292        assert_eq!(
15293            canonical_src, canonical_rt,
15294            "round-trip diverged\n  src: {canonical_src}\n   rt: {canonical_rt}\n   md: {md:?}"
15295        );
15296    }
15297
15298    #[test]
15299    fn issue_547_single_item_no_attrs_roundtrip() {
15300        assert_roundtrip_byte_identical(
15301            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"only"}]}]}]}]}"#,
15302        );
15303    }
15304
15305    #[test]
15306    fn issue_547_many_items_no_attrs_roundtrip() {
15307        assert_roundtrip_byte_identical(
15308            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"}]}]}]}]}"#,
15309        );
15310    }
15311
15312    #[test]
15313    fn issue_547_non_default_order_preserved() {
15314        // When order != 1, attrs must still be serialized (fix must not
15315        // over-eagerly drop attrs).
15316        assert_roundtrip_byte_identical(
15317            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":5},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"fifth"}]}]}]}]}"#,
15318        );
15319    }
15320
15321    #[test]
15322    fn issue_547_nested_ordered_in_ordered_no_attrs_roundtrip() {
15323        // Outer and inner both omit attrs; fix must apply at every level.
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":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"inner"}]}]}]}]}]}]}"#,
15326        );
15327    }
15328
15329    #[test]
15330    fn issue_547_ordered_nested_in_bullet_no_attrs_roundtrip() {
15331        assert_roundtrip_byte_identical(
15332            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"}]}]}]}]}]}]}"#,
15333        );
15334    }
15335
15336    #[test]
15337    fn issue_547_bullet_nested_in_ordered_no_attrs_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":"outer"}]},{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"nested"}]}]}]}]}]}]}"#,
15340        );
15341    }
15342
15343    #[test]
15344    fn issue_547_ordered_list_between_paragraphs_roundtrip() {
15345        assert_roundtrip_byte_identical(
15346            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"}]}]}"#,
15347        );
15348    }
15349
15350    #[test]
15351    fn issue_547_ordered_list_with_marked_text_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":"bold","marks":[{"type":"strong"}]}]}]}]}]}"#,
15354        );
15355    }
15356
15357    #[test]
15358    fn issue_547_ordered_list_with_link_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":"site","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]}]}]}]}]}"#,
15361        );
15362    }
15363
15364    #[test]
15365    fn issue_547_ordered_list_with_hardbreak_roundtrip() {
15366        assert_roundtrip_byte_identical(
15367            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"}]}]}]}]}"#,
15368        );
15369    }
15370
15371    #[test]
15372    fn issue_547_triple_nested_ordered_roundtrip() {
15373        assert_roundtrip_byte_identical(
15374            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"}]}]}]}]}]}]}]}]}"#,
15375        );
15376    }
15377
15378    #[test]
15379    fn issue_547_ordered_list_heading_rule_mix_roundtrip() {
15380        assert_roundtrip_byte_identical(
15381            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"}]}"#,
15382        );
15383    }
15384
15385    #[test]
15386    fn issue_547_ordered_list_listitem_localid_roundtrip() {
15387        // listItem attrs must coexist with the no-attrs outer orderedList.
15388        assert_roundtrip_byte_identical(
15389            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","attrs":{"localId":"li-001"},"content":[{"type":"paragraph","content":[{"type":"text","text":"first"}]}]}]}]}"#,
15390        );
15391    }
15392
15393    #[test]
15394    fn issue_547_explicit_order_one_preserved_roundtrip() {
15395        // Inverse regression (see PR #562 comment 4266630848): when the source
15396        // ADF has an explicit `"attrs": {"order": 1}` the round-trip must
15397        // preserve it, not strip it. A trailing `{order=1}` signal on the
15398        // rendered markdown distinguishes explicit-default from omitted attrs.
15399        assert_roundtrip_byte_identical(
15400            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"First item"}]}]}]}]}"#,
15401        );
15402    }
15403
15404    #[test]
15405    fn issue_547_explicit_order_one_nested_preserved_roundtrip() {
15406        // Both outer and inner orderedList have explicit `order: 1`; both must
15407        // be preserved across the round-trip independently.
15408        assert_roundtrip_byte_identical(
15409            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"}]}]}]}]}]}]}"#,
15410        );
15411    }
15412
15413    #[test]
15414    fn issue_547_mixed_explicit_and_implicit_order_roundtrip() {
15415        // Sibling orderedLists with different attrs presence must round-trip
15416        // independently: first has explicit `order: 1`, second omits attrs.
15417        assert_roundtrip_byte_identical(
15418            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"}]}]}]}]}"#,
15419        );
15420    }
15421
15422    #[test]
15423    fn issue_547_explicit_order_one_with_listitem_localid_roundtrip() {
15424        // Explicit `order: 1` outer, plus a listItem `localId` inside — the
15425        // trailing `{order=1}` line must not swallow or collide with listItem
15426        // attrs.
15427        assert_roundtrip_byte_identical(
15428            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"}]}]}]}]}"#,
15429        );
15430    }
15431
15432    #[test]
15433    fn issue_547_order_attr_signal_appears_only_for_explicit_one() {
15434        // Render-layer guard: `{order=1}` appears in markdown only when the
15435        // source ADF has explicit `attrs.order=1`. No signal for attrs=None,
15436        // no signal for attrs.order>1 (marker already encodes the value).
15437        let no_attrs = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]}]}"#;
15438        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"}]}]}]}]}"#;
15439        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"}]}]}]}]}"#;
15440
15441        let md_no =
15442            adf_to_markdown(&serde_json::from_str::<AdfDocument>(no_attrs).unwrap()).unwrap();
15443        let md_one =
15444            adf_to_markdown(&serde_json::from_str::<AdfDocument>(explicit_one).unwrap()).unwrap();
15445        let md_five =
15446            adf_to_markdown(&serde_json::from_str::<AdfDocument>(order_five).unwrap()).unwrap();
15447
15448        assert!(
15449            !md_no.contains("{order="),
15450            "no-attrs source must not emit order signal, got: {md_no:?}"
15451        );
15452        assert!(
15453            md_one.contains("{order=1}"),
15454            "explicit order=1 must emit trailing signal, got: {md_one:?}"
15455        );
15456        assert!(
15457            !md_five.contains("{order="),
15458            "order=5 is already encoded by marker; must not emit signal, got: {md_five:?}"
15459        );
15460    }
15461
15462    // ── File media round-trip tests ─────────────────────────────────────
15463
15464    #[test]
15465    fn file_media_roundtrip() {
15466        // ADF with a Confluence file attachment (type:file media)
15467        let adf_doc = serde_json::json!({
15468            "type": "doc",
15469            "version": 1,
15470            "content": [{
15471                "type": "mediaSingle",
15472                "attrs": {"layout": "center"},
15473                "content": [{
15474                    "type": "media",
15475                    "attrs": {
15476                        "type": "file",
15477                        "id": "6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d",
15478                        "collection": "contentId-8220672100",
15479                        "height": 56,
15480                        "width": 312,
15481                        "alt": "Screenshot.png"
15482                    }
15483                }]
15484            }]
15485        });
15486        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
15487        let md = adf_to_markdown(&doc).unwrap();
15488        assert!(
15489            md.contains("type=file"),
15490            "expected type=file in markdown, got: {md}"
15491        );
15492        assert!(
15493            md.contains("id=6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d"),
15494            "expected id in markdown, got: {md}"
15495        );
15496        assert!(
15497            md.contains("collection=contentId-8220672100"),
15498            "expected collection in markdown, got: {md}"
15499        );
15500        // Round-trip back to ADF
15501        let doc2 = markdown_to_adf(&md).unwrap();
15502        let ms = &doc2.content[0];
15503        assert_eq!(ms.node_type, "mediaSingle");
15504        let media = &ms.content.as_ref().unwrap()[0];
15505        assert_eq!(media.node_type, "media");
15506        let attrs = media.attrs.as_ref().unwrap();
15507        assert_eq!(attrs["type"], "file");
15508        assert_eq!(attrs["id"], "6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d");
15509        assert_eq!(attrs["collection"], "contentId-8220672100");
15510        assert_eq!(attrs["height"], 56);
15511        assert_eq!(attrs["width"], 312);
15512        assert_eq!(attrs["alt"], "Screenshot.png");
15513    }
15514
15515    /// Issue #550: roundtrip of mediaSingle with file-type media preserves all
15516    /// file attributes (type, id, collection, width, height). Regression guard
15517    /// for the exact reproducer in the issue body.
15518    #[test]
15519    fn file_media_roundtrip_issue_550_reproducer() {
15520        let adf_json = r#"{
15521          "version": 1,
15522          "type": "doc",
15523          "content": [
15524            {
15525              "type": "mediaSingle",
15526              "attrs": {"layout": "center"},
15527              "content": [
15528                {
15529                  "type": "media",
15530                  "attrs": {
15531                    "type": "file",
15532                    "id": "abc-123-def-456",
15533                    "collection": "my-collection",
15534                    "width": 941,
15535                    "height": 655
15536                  }
15537                }
15538              ]
15539            }
15540          ]
15541        }"#;
15542        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15543        let md = adf_to_markdown(&doc).unwrap();
15544        let rt = markdown_to_adf(&md).unwrap();
15545        let expected: serde_json::Value = serde_json::from_str(adf_json).unwrap();
15546        let actual = serde_json::to_value(&rt).unwrap();
15547        assert_eq!(
15548            actual, expected,
15549            "roundtrip should preserve file media attrs; md was:\n{md}"
15550        );
15551    }
15552
15553    /// Issue #550 (updated reproducer): roundtrip of a file-media `id`
15554    /// containing spaces must not truncate the value. Before the fix, the
15555    /// JFM renderer emitted `id=abc 123 def 456` unquoted and the parser
15556    /// treated the first space as a value terminator, so the `id` became
15557    /// `"abc"` after round-trip.
15558    #[test]
15559    fn file_media_roundtrip_id_with_spaces() {
15560        let adf_json = r#"{
15561          "version": 1,
15562          "type": "doc",
15563          "content": [
15564            {
15565              "type": "mediaSingle",
15566              "attrs": {"layout": "center"},
15567              "content": [
15568                {
15569                  "type": "media",
15570                  "attrs": {
15571                    "type": "file",
15572                    "id": "abc 123 def 456",
15573                    "collection": "my-collection",
15574                    "width": 800,
15575                    "height": 600
15576                  }
15577                }
15578              ]
15579            }
15580          ]
15581        }"#;
15582        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15583        let md = adf_to_markdown(&doc).unwrap();
15584        assert!(
15585            md.contains(r#"id="abc 123 def 456""#),
15586            "id with spaces should be quoted in JFM, got:\n{md}"
15587        );
15588        let rt = markdown_to_adf(&md).unwrap();
15589        let expected: serde_json::Value = serde_json::from_str(adf_json).unwrap();
15590        let actual = serde_json::to_value(&rt).unwrap();
15591        assert_eq!(
15592            actual, expected,
15593            "space-containing id must round-trip; md was:\n{md}"
15594        );
15595    }
15596
15597    /// Space-containing `collection` values must round-trip.
15598    #[test]
15599    fn file_media_roundtrip_collection_with_spaces() {
15600        let adf_json = r#"{
15601          "version": 1,
15602          "type": "doc",
15603          "content": [
15604            {
15605              "type": "mediaSingle",
15606              "attrs": {"layout": "center"},
15607              "content": [
15608                {
15609                  "type": "media",
15610                  "attrs": {
15611                    "type": "file",
15612                    "id": "abc-123",
15613                    "collection": "my collection with spaces"
15614                  }
15615                }
15616              ]
15617            }
15618          ]
15619        }"#;
15620        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15621        let md = adf_to_markdown(&doc).unwrap();
15622        let rt = markdown_to_adf(&md).unwrap();
15623        let media = &rt.content[0].content.as_ref().unwrap()[0];
15624        assert_eq!(
15625            media.attrs.as_ref().unwrap()["collection"],
15626            "my collection with spaces"
15627        );
15628    }
15629
15630    /// Space-containing `occurrenceKey` values must round-trip.
15631    #[test]
15632    fn file_media_roundtrip_occurrence_key_with_spaces() {
15633        let adf_json = r#"{
15634          "version": 1,
15635          "type": "doc",
15636          "content": [
15637            {
15638              "type": "mediaSingle",
15639              "attrs": {"layout": "center"},
15640              "content": [
15641                {
15642                  "type": "media",
15643                  "attrs": {
15644                    "type": "file",
15645                    "id": "x",
15646                    "collection": "y",
15647                    "occurrenceKey": "key with spaces"
15648                  }
15649                }
15650              ]
15651            }
15652          ]
15653        }"#;
15654        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15655        let md = adf_to_markdown(&doc).unwrap();
15656        let rt = markdown_to_adf(&md).unwrap();
15657        let media = &rt.content[0].content.as_ref().unwrap()[0];
15658        assert_eq!(
15659            media.attrs.as_ref().unwrap()["occurrenceKey"],
15660            "key with spaces"
15661        );
15662    }
15663
15664    /// Values with embedded `"` must be escape-quoted and round-trip.
15665    #[test]
15666    fn file_media_roundtrip_id_with_quote_char() {
15667        let adf_json = r#"{
15668          "version": 1,
15669          "type": "doc",
15670          "content": [
15671            {
15672              "type": "mediaSingle",
15673              "attrs": {"layout": "center"},
15674              "content": [
15675                {
15676                  "type": "media",
15677                  "attrs": {
15678                    "type": "file",
15679                    "id": "a\"b\"c",
15680                    "collection": "col"
15681                  }
15682                }
15683              ]
15684            }
15685          ]
15686        }"#;
15687        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15688        let md = adf_to_markdown(&doc).unwrap();
15689        let rt = markdown_to_adf(&md).unwrap();
15690        let media = &rt.content[0].content.as_ref().unwrap()[0];
15691        assert_eq!(media.attrs.as_ref().unwrap()["id"], "a\"b\"c");
15692    }
15693
15694    /// `mediaInline` string attrs with spaces must round-trip (parallel fix
15695    /// for the inline-directive rendering path).
15696    #[test]
15697    fn media_inline_roundtrip_id_with_spaces() {
15698        let adf_json = r#"{
15699          "version": 1,
15700          "type": "doc",
15701          "content": [
15702            {
15703              "type": "paragraph",
15704              "content": [
15705                {"type": "text", "text": "before "},
15706                {
15707                  "type": "mediaInline",
15708                  "attrs": {
15709                    "type": "file",
15710                    "id": "a b c",
15711                    "collection": "my col"
15712                  }
15713                },
15714                {"type": "text", "text": " after"}
15715              ]
15716            }
15717          ]
15718        }"#;
15719        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15720        let md = adf_to_markdown(&doc).unwrap();
15721        let rt = markdown_to_adf(&md).unwrap();
15722        let inline = &rt.content[0].content.as_ref().unwrap()[1];
15723        assert_eq!(inline.node_type, "mediaInline");
15724        let attrs = inline.attrs.as_ref().unwrap();
15725        assert_eq!(attrs["id"], "a b c");
15726        assert_eq!(attrs["collection"], "my col");
15727    }
15728
15729    /// Issue #550: `occurrenceKey` attribute is a standard ADF media attr and
15730    /// must be preserved through ADF→JFM→ADF roundtrip.
15731    #[test]
15732    fn file_media_roundtrip_preserves_occurrence_key() {
15733        let adf_json = r#"{
15734          "version": 1,
15735          "type": "doc",
15736          "content": [
15737            {
15738              "type": "mediaSingle",
15739              "attrs": {"layout": "center"},
15740              "content": [
15741                {
15742                  "type": "media",
15743                  "attrs": {
15744                    "type": "file",
15745                    "id": "abc-123",
15746                    "collection": "my-collection",
15747                    "occurrenceKey": "unique-key-xyz",
15748                    "width": 200,
15749                    "height": 100
15750                  }
15751                }
15752              ]
15753            }
15754          ]
15755        }"#;
15756        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15757        let md = adf_to_markdown(&doc).unwrap();
15758        assert!(
15759            md.contains("occurrenceKey=unique-key-xyz"),
15760            "expected occurrenceKey in markdown, got: {md}"
15761        );
15762        let rt = markdown_to_adf(&md).unwrap();
15763        let media = &rt.content[0].content.as_ref().unwrap()[0];
15764        let attrs = media.attrs.as_ref().unwrap();
15765        assert_eq!(attrs["occurrenceKey"], "unique-key-xyz");
15766        assert_eq!(attrs["type"], "file");
15767        assert_eq!(attrs["id"], "abc-123");
15768        assert_eq!(attrs["collection"], "my-collection");
15769    }
15770
15771    // ── mediaSingle caption tests (issue #470) ──────────────────────────
15772
15773    #[test]
15774    fn media_single_caption_adf_to_markdown() {
15775        let adf_doc = serde_json::json!({
15776            "type": "doc",
15777            "version": 1,
15778            "content": [{
15779                "type": "mediaSingle",
15780                "attrs": {"layout": "center", "width": 400, "widthType": "pixel"},
15781                "content": [
15782                    {
15783                        "type": "media",
15784                        "attrs": {
15785                            "id": "aabbccdd-1234-5678-abcd-aabbccdd1234",
15786                            "type": "file",
15787                            "collection": "contentId-123456",
15788                            "width": 800,
15789                            "height": 600
15790                        }
15791                    },
15792                    {
15793                        "type": "caption",
15794                        "content": [{"type": "text", "text": "An image caption here"}]
15795                    }
15796                ]
15797            }]
15798        });
15799        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
15800        let md = adf_to_markdown(&doc).unwrap();
15801        assert!(
15802            md.contains(":::caption"),
15803            "expected :::caption in markdown, got: {md}"
15804        );
15805        assert!(
15806            md.contains("An image caption here"),
15807            "expected caption text in markdown, got: {md}"
15808        );
15809    }
15810
15811    #[test]
15812    fn media_single_caption_markdown_to_adf() {
15813        let md = "![Screenshot](){type=file id=abc-123 collection=contentId-456 height=600 width=800}\n:::caption\nAn image caption here\n:::\n";
15814        let doc = markdown_to_adf(md).unwrap();
15815        let ms = &doc.content[0];
15816        assert_eq!(ms.node_type, "mediaSingle");
15817        let content = ms.content.as_ref().unwrap();
15818        assert_eq!(content.len(), 2, "expected media + caption children");
15819        assert_eq!(content[0].node_type, "media");
15820        assert_eq!(content[1].node_type, "caption");
15821        let caption_content = content[1].content.as_ref().unwrap();
15822        assert_eq!(
15823            caption_content[0].text.as_deref(),
15824            Some("An image caption here")
15825        );
15826    }
15827
15828    #[test]
15829    fn media_single_caption_round_trip() {
15830        // Full round-trip: ADF → JFM → ADF preserves caption
15831        let adf_doc = serde_json::json!({
15832            "type": "doc",
15833            "version": 1,
15834            "content": [{
15835                "type": "mediaSingle",
15836                "attrs": {"layout": "center", "width": 400, "widthType": "pixel"},
15837                "content": [
15838                    {
15839                        "type": "media",
15840                        "attrs": {
15841                            "id": "aabbccdd-1234-5678-abcd-aabbccdd1234",
15842                            "type": "file",
15843                            "collection": "contentId-123456",
15844                            "width": 800,
15845                            "height": 600
15846                        }
15847                    },
15848                    {
15849                        "type": "caption",
15850                        "content": [{"type": "text", "text": "An image caption here"}]
15851                    }
15852                ]
15853            }]
15854        });
15855        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
15856        let md = adf_to_markdown(&doc).unwrap();
15857        let doc2 = markdown_to_adf(&md).unwrap();
15858        let ms = &doc2.content[0];
15859        assert_eq!(ms.node_type, "mediaSingle");
15860        let content = ms.content.as_ref().unwrap();
15861        assert_eq!(
15862            content.len(),
15863            2,
15864            "expected media + caption after round-trip"
15865        );
15866        assert_eq!(content[1].node_type, "caption");
15867        let caption_content = content[1].content.as_ref().unwrap();
15868        assert_eq!(
15869            caption_content[0].text.as_deref(),
15870            Some("An image caption here")
15871        );
15872    }
15873
15874    #[test]
15875    fn media_single_caption_with_inline_marks() {
15876        let adf_doc = serde_json::json!({
15877            "type": "doc",
15878            "version": 1,
15879            "content": [{
15880                "type": "mediaSingle",
15881                "attrs": {"layout": "center"},
15882                "content": [
15883                    {
15884                        "type": "media",
15885                        "attrs": {"type": "external", "url": "https://example.com/img.png"}
15886                    },
15887                    {
15888                        "type": "caption",
15889                        "content": [
15890                            {"type": "text", "text": "A "},
15891                            {"type": "text", "text": "bold", "marks": [{"type": "strong"}]},
15892                            {"type": "text", "text": " caption"}
15893                        ]
15894                    }
15895                ]
15896            }]
15897        });
15898        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
15899        let md = adf_to_markdown(&doc).unwrap();
15900        assert!(
15901            md.contains("**bold**"),
15902            "expected bold in caption, got: {md}"
15903        );
15904
15905        let doc2 = markdown_to_adf(&md).unwrap();
15906        let content = doc2.content[0].content.as_ref().unwrap();
15907        assert_eq!(content.len(), 2, "expected media + caption");
15908        assert_eq!(content[1].node_type, "caption");
15909        let caption_inlines = content[1].content.as_ref().unwrap();
15910        let bold_node = caption_inlines
15911            .iter()
15912            .find(|n| n.text.as_deref() == Some("bold"))
15913            .unwrap();
15914        let marks = bold_node.marks.as_ref().unwrap();
15915        assert_eq!(marks[0].mark_type, "strong");
15916    }
15917
15918    #[test]
15919    fn media_single_no_caption_unaffected() {
15920        // Existing mediaSingle without caption should be unaffected
15921        let adf_doc = serde_json::json!({
15922            "type": "doc",
15923            "version": 1,
15924            "content": [{
15925                "type": "mediaSingle",
15926                "attrs": {"layout": "center"},
15927                "content": [{
15928                    "type": "media",
15929                    "attrs": {"type": "external", "url": "https://example.com/img.png"}
15930                }]
15931            }]
15932        });
15933        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
15934        let md = adf_to_markdown(&doc).unwrap();
15935        assert!(
15936            !md.contains(":::caption"),
15937            "should not emit caption when none present"
15938        );
15939        let doc2 = markdown_to_adf(&md).unwrap();
15940        let content = doc2.content[0].content.as_ref().unwrap();
15941        assert_eq!(content.len(), 1, "should only have media child");
15942        assert_eq!(content[0].node_type, "media");
15943    }
15944
15945    #[test]
15946    fn media_single_empty_caption_round_trip() {
15947        // Caption node with no content should still round-trip
15948        let adf_doc = serde_json::json!({
15949            "type": "doc",
15950            "version": 1,
15951            "content": [{
15952                "type": "mediaSingle",
15953                "attrs": {"layout": "center"},
15954                "content": [
15955                    {
15956                        "type": "media",
15957                        "attrs": {"type": "external", "url": "https://example.com/img.png"}
15958                    },
15959                    {
15960                        "type": "caption"
15961                    }
15962                ]
15963            }]
15964        });
15965        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
15966        let md = adf_to_markdown(&doc).unwrap();
15967        assert!(
15968            md.contains(":::caption"),
15969            "expected :::caption even for empty caption, got: {md}"
15970        );
15971        assert!(
15972            md.contains(":::\n"),
15973            "expected closing ::: fence, got: {md}"
15974        );
15975    }
15976
15977    #[test]
15978    fn media_single_external_caption_round_trip() {
15979        // External image with caption round-trips
15980        let md = "![alt](https://example.com/img.png)\n:::caption\nImage description\n:::\n";
15981        let doc = markdown_to_adf(md).unwrap();
15982        let ms = &doc.content[0];
15983        assert_eq!(ms.node_type, "mediaSingle");
15984        let content = ms.content.as_ref().unwrap();
15985        assert_eq!(content.len(), 2);
15986        assert_eq!(content[0].node_type, "media");
15987        assert_eq!(content[1].node_type, "caption");
15988
15989        let md2 = adf_to_markdown(&doc).unwrap();
15990        let doc2 = markdown_to_adf(&md2).unwrap();
15991        let content2 = doc2.content[0].content.as_ref().unwrap();
15992        assert_eq!(content2.len(), 2);
15993        assert_eq!(content2[1].node_type, "caption");
15994        let caption_text = content2[1].content.as_ref().unwrap();
15995        assert_eq!(caption_text[0].text.as_deref(), Some("Image description"));
15996    }
15997
15998    // ── mediaSingle caption localId tests (issue #524) ─────────────────
15999
16000    #[test]
16001    fn media_single_caption_localid_roundtrip() {
16002        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"}]}]}]}"#;
16003        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16004        let md = adf_to_markdown(&doc).unwrap();
16005        assert!(
16006            md.contains("localId=9da8c2104471"),
16007            "caption localId should appear in markdown: {md}"
16008        );
16009        let rt = markdown_to_adf(&md).unwrap();
16010        let content = rt.content[0].content.as_ref().unwrap();
16011        let caption = &content[1];
16012        assert_eq!(caption.node_type, "caption");
16013        assert_eq!(
16014            caption.attrs.as_ref().unwrap()["localId"],
16015            "9da8c2104471",
16016            "caption localId should round-trip"
16017        );
16018    }
16019
16020    #[test]
16021    fn media_single_caption_without_localid() {
16022        let md = "![Screenshot](){type=file id=abc-123 collection=contentId-456 height=600 width=800}\n:::caption\nPlain caption\n:::\n";
16023        let doc = markdown_to_adf(md).unwrap();
16024        let caption = &doc.content[0].content.as_ref().unwrap()[1];
16025        assert_eq!(caption.node_type, "caption");
16026        assert!(
16027            caption.attrs.is_none(),
16028            "caption without localId should not gain attrs"
16029        );
16030        let md2 = adf_to_markdown(&doc).unwrap();
16031        assert!(
16032            !md2.contains("localId"),
16033            "no localId should appear in output: {md2}"
16034        );
16035    }
16036
16037    #[test]
16038    fn media_single_caption_localid_stripped_when_option_set() {
16039        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"}]}]}]}"#;
16040        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16041        let opts = RenderOptions {
16042            strip_local_ids: true,
16043            ..Default::default()
16044        };
16045        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
16046        assert!(!md.contains("localId"), "localId should be stripped: {md}");
16047    }
16048
16049    #[test]
16050    fn table_width_roundtrip() {
16051        // ADF table with width attribute
16052        let adf_doc = serde_json::json!({
16053            "type": "doc",
16054            "version": 1,
16055            "content": [{
16056                "type": "table",
16057                "attrs": {"layout": "default", "width": 760.0},
16058                "content": [{
16059                    "type": "tableRow",
16060                    "content": [{
16061                        "type": "tableHeader",
16062                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "H"}]}]
16063                    }]
16064                }]
16065            }]
16066        });
16067        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16068        let md = adf_to_markdown(&doc).unwrap();
16069        assert!(
16070            md.contains("width=760.0"),
16071            "expected width=760.0 in markdown (float preserved), got: {md}"
16072        );
16073        // Round-trip back to ADF
16074        let doc2 = markdown_to_adf(&md).unwrap();
16075        let table = &doc2.content[0];
16076        assert_eq!(table.node_type, "table");
16077        let table_attrs = table.attrs.as_ref().unwrap();
16078        assert_eq!(table_attrs["width"], 760.0);
16079        assert!(
16080            table_attrs["width"].is_f64(),
16081            "expected float width to be preserved as f64, got: {:?}",
16082            table_attrs["width"]
16083        );
16084    }
16085
16086    #[test]
16087    fn table_integer_width_roundtrip_preserves_integer() {
16088        // Issue #577: Integer width in ADF must survive roundtrip without being
16089        // coerced to a float.
16090        let adf_doc = serde_json::json!({
16091            "type": "doc",
16092            "version": 1,
16093            "content": [{
16094                "type": "table",
16095                "attrs": {
16096                    "isNumberColumnEnabled": false,
16097                    "layout": "center",
16098                    "localId": "abc-123",
16099                    "width": 1420
16100                },
16101                "content": [{
16102                    "type": "tableRow",
16103                    "content": [{
16104                        "type": "tableCell",
16105                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Cell"}]}]
16106                    }]
16107                }]
16108            }]
16109        });
16110        let doc: crate::atlassian::adf::AdfDocument =
16111            serde_json::from_value(adf_doc.clone()).unwrap();
16112        let md = adf_to_markdown(&doc).unwrap();
16113        assert!(
16114            md.contains("width=1420"),
16115            "expected width=1420 in markdown, got: {md}"
16116        );
16117        assert!(
16118            !md.contains("width=1420.0"),
16119            "integer width should not be rendered with decimal: {md}"
16120        );
16121
16122        let doc2 = markdown_to_adf(&md).unwrap();
16123        let table = &doc2.content[0];
16124        assert_eq!(table.node_type, "table");
16125        let table_attrs = table.attrs.as_ref().unwrap();
16126        assert_eq!(table_attrs["width"], 1420);
16127        assert!(
16128            table_attrs["width"].is_u64() || table_attrs["width"].is_i64(),
16129            "width should remain an integer, got: {:?}",
16130            table_attrs["width"]
16131        );
16132        assert!(
16133            !table_attrs["width"].is_f64(),
16134            "width should not be a float, got: {:?}",
16135            table_attrs["width"]
16136        );
16137
16138        // Full byte-fidelity: re-serialized ADF should match original JSON.
16139        let roundtripped = serde_json::to_value(&doc2).unwrap();
16140        let orig_width = &adf_doc["content"][0]["attrs"]["width"];
16141        let rt_width = &roundtripped["content"][0]["attrs"]["width"];
16142        assert_eq!(
16143            orig_width, rt_width,
16144            "width value must roundtrip byte-for-byte"
16145        );
16146    }
16147
16148    #[test]
16149    fn table_fractional_width_roundtrip() {
16150        // Fractional float widths should also roundtrip faithfully.
16151        let adf_doc = serde_json::json!({
16152            "type": "doc",
16153            "version": 1,
16154            "content": [{
16155                "type": "table",
16156                "attrs": {"layout": "default", "width": 760.5},
16157                "content": [{
16158                    "type": "tableRow",
16159                    "content": [{
16160                        "type": "tableHeader",
16161                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "H"}]}]
16162                    }]
16163                }]
16164            }]
16165        });
16166        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16167        let md = adf_to_markdown(&doc).unwrap();
16168        assert!(
16169            md.contains("width=760.5"),
16170            "expected width=760.5 in markdown, got: {md}"
16171        );
16172        let doc2 = markdown_to_adf(&md).unwrap();
16173        let table_attrs = doc2.content[0].attrs.as_ref().unwrap();
16174        assert_eq!(table_attrs["width"], 760.5);
16175        assert!(table_attrs["width"].is_f64());
16176    }
16177
16178    #[test]
16179    fn pipe_table_integer_width_roundtrip() {
16180        // Exercises the try_table() attrs-on-next-line parsing path.
16181        let md = "| A | B |\n|---|---|\n| 1 | 2 |\n{layout=default width=1420}\n";
16182        let doc = markdown_to_adf(md).unwrap();
16183        let table = &doc.content[0];
16184        assert_eq!(table.node_type, "table");
16185        let attrs = table.attrs.as_ref().unwrap();
16186        assert_eq!(attrs["width"], 1420);
16187        assert!(
16188            attrs["width"].is_u64() || attrs["width"].is_i64(),
16189            "pipe-table width must stay integer, got: {:?}",
16190            attrs["width"]
16191        );
16192    }
16193
16194    #[test]
16195    fn file_media_width_type_roundtrip() {
16196        // mediaSingle with widthType:pixel should survive round-trip
16197        let adf_doc = serde_json::json!({
16198            "type": "doc",
16199            "version": 1,
16200            "content": [{
16201                "type": "mediaSingle",
16202                "attrs": {"layout": "center", "width": 312, "widthType": "pixel"},
16203                "content": [{
16204                    "type": "media",
16205                    "attrs": {
16206                        "type": "file",
16207                        "id": "abc123",
16208                        "collection": "contentId-999",
16209                        "height": 56,
16210                        "width": 312
16211                    }
16212                }]
16213            }]
16214        });
16215        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16216        let md = adf_to_markdown(&doc).unwrap();
16217        assert!(
16218            md.contains("widthType=pixel"),
16219            "expected widthType=pixel in markdown, got: {md}"
16220        );
16221        let doc2 = markdown_to_adf(&md).unwrap();
16222        let ms = &doc2.content[0];
16223        let ms_attrs = ms.attrs.as_ref().unwrap();
16224        assert_eq!(ms_attrs["widthType"], "pixel");
16225        assert_eq!(ms_attrs["width"], 312);
16226    }
16227
16228    #[test]
16229    fn file_media_mode_roundtrip() {
16230        // mediaSingle with mode attr should survive round-trip (issue #431)
16231        let adf_doc = serde_json::json!({
16232            "type": "doc",
16233            "version": 1,
16234            "content": [{
16235                "type": "mediaSingle",
16236                "attrs": {"layout": "wide", "mode": "wide", "width": 1200},
16237                "content": [{
16238                    "type": "media",
16239                    "attrs": {
16240                        "type": "file",
16241                        "id": "abc123",
16242                        "collection": "test",
16243                        "width": 1200,
16244                        "height": 600
16245                    }
16246                }]
16247            }]
16248        });
16249        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16250        let md = adf_to_markdown(&doc).unwrap();
16251        assert!(
16252            md.contains("mode=wide"),
16253            "expected mode=wide in markdown, got: {md}"
16254        );
16255        let doc2 = markdown_to_adf(&md).unwrap();
16256        let ms = &doc2.content[0];
16257        let ms_attrs = ms.attrs.as_ref().unwrap();
16258        assert_eq!(ms_attrs["mode"], "wide");
16259        assert_eq!(ms_attrs["layout"], "wide");
16260        assert_eq!(ms_attrs["width"], 1200);
16261    }
16262
16263    #[test]
16264    fn external_media_mode_roundtrip() {
16265        // External mediaSingle with mode attr should survive round-trip (issue #431)
16266        let adf_doc = serde_json::json!({
16267            "type": "doc",
16268            "version": 1,
16269            "content": [{
16270                "type": "mediaSingle",
16271                "attrs": {"layout": "wide", "mode": "wide"},
16272                "content": [{
16273                    "type": "media",
16274                    "attrs": {
16275                        "type": "external",
16276                        "url": "https://example.com/image.png"
16277                    }
16278                }]
16279            }]
16280        });
16281        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16282        let md = adf_to_markdown(&doc).unwrap();
16283        assert!(
16284            md.contains("mode=wide"),
16285            "expected mode=wide in markdown, got: {md}"
16286        );
16287        let doc2 = markdown_to_adf(&md).unwrap();
16288        let ms = &doc2.content[0];
16289        let ms_attrs = ms.attrs.as_ref().unwrap();
16290        assert_eq!(ms_attrs["mode"], "wide");
16291        assert_eq!(ms_attrs["layout"], "wide");
16292    }
16293
16294    #[test]
16295    fn media_mode_only_roundtrip() {
16296        // mediaSingle with mode but default layout should still preserve mode (issue #431)
16297        let adf_doc = serde_json::json!({
16298            "type": "doc",
16299            "version": 1,
16300            "content": [{
16301                "type": "mediaSingle",
16302                "attrs": {"layout": "center", "mode": "default"},
16303                "content": [{
16304                    "type": "media",
16305                    "attrs": {
16306                        "type": "external",
16307                        "url": "https://example.com/image.png"
16308                    }
16309                }]
16310            }]
16311        });
16312        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16313        let md = adf_to_markdown(&doc).unwrap();
16314        assert!(
16315            md.contains("mode=default"),
16316            "expected mode=default in markdown, got: {md}"
16317        );
16318        let doc2 = markdown_to_adf(&md).unwrap();
16319        let ms = &doc2.content[0];
16320        let ms_attrs = ms.attrs.as_ref().unwrap();
16321        assert_eq!(ms_attrs["mode"], "default");
16322    }
16323
16324    #[test]
16325    fn file_media_hex_localid_roundtrip() {
16326        // Issue #432: short hex localId (non-UUID) must survive round-trip
16327        let adf_doc = serde_json::json!({
16328            "type": "doc",
16329            "version": 1,
16330            "content": [{
16331                "type": "mediaSingle",
16332                "attrs": {"layout": "wide", "width": 1200, "widthType": "pixel"},
16333                "content": [{
16334                    "type": "media",
16335                    "attrs": {
16336                        "type": "file",
16337                        "id": "eb7a9c3b-314e-4458-8200-4b22b67b122e",
16338                        "collection": "contentId-123",
16339                        "height": 484,
16340                        "width": 915,
16341                        "alt": "image.png",
16342                        "localId": "0e79f58ac382"
16343                    }
16344                }]
16345            }]
16346        });
16347        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16348        let md = adf_to_markdown(&doc).unwrap();
16349        assert!(
16350            md.contains("localId=0e79f58ac382"),
16351            "expected localId=0e79f58ac382 in markdown, got: {md}"
16352        );
16353        let doc2 = markdown_to_adf(&md).unwrap();
16354        let ms = &doc2.content[0];
16355        let media = &ms.content.as_ref().unwrap()[0];
16356        let attrs = media.attrs.as_ref().unwrap();
16357        assert_eq!(attrs["localId"], "0e79f58ac382");
16358    }
16359
16360    #[test]
16361    fn file_media_uuid_localid_roundtrip() {
16362        // UUID-format localId must also survive round-trip
16363        let adf_doc = serde_json::json!({
16364            "type": "doc",
16365            "version": 1,
16366            "content": [{
16367                "type": "mediaSingle",
16368                "attrs": {"layout": "center"},
16369                "content": [{
16370                    "type": "media",
16371                    "attrs": {
16372                        "type": "file",
16373                        "id": "abc-123",
16374                        "collection": "contentId-456",
16375                        "height": 100,
16376                        "width": 200,
16377                        "localId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
16378                    }
16379                }]
16380            }]
16381        });
16382        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16383        let md = adf_to_markdown(&doc).unwrap();
16384        assert!(
16385            md.contains("localId=a1b2c3d4-e5f6-7890-abcd-ef1234567890"),
16386            "expected UUID localId in markdown, got: {md}"
16387        );
16388        let doc2 = markdown_to_adf(&md).unwrap();
16389        let media = &doc2.content[0].content.as_ref().unwrap()[0];
16390        let attrs = media.attrs.as_ref().unwrap();
16391        assert_eq!(attrs["localId"], "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
16392    }
16393
16394    #[test]
16395    fn file_media_null_uuid_localid_stripped() {
16396        // Null UUID localId should be stripped (consistent with other node types)
16397        let adf_doc = serde_json::json!({
16398            "type": "doc",
16399            "version": 1,
16400            "content": [{
16401                "type": "mediaSingle",
16402                "attrs": {"layout": "center"},
16403                "content": [{
16404                    "type": "media",
16405                    "attrs": {
16406                        "type": "file",
16407                        "id": "abc-123",
16408                        "collection": "contentId-456",
16409                        "height": 100,
16410                        "width": 200,
16411                        "localId": "00000000-0000-0000-0000-000000000000"
16412                    }
16413                }]
16414            }]
16415        });
16416        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16417        let md = adf_to_markdown(&doc).unwrap();
16418        assert!(
16419            !md.contains("localId="),
16420            "null UUID localId should be stripped, got: {md}"
16421        );
16422    }
16423
16424    #[test]
16425    fn file_media_localid_stripped_when_option_set() {
16426        // localId should be stripped when strip_local_ids option is enabled
16427        let adf_doc = serde_json::json!({
16428            "type": "doc",
16429            "version": 1,
16430            "content": [{
16431                "type": "mediaSingle",
16432                "attrs": {"layout": "center"},
16433                "content": [{
16434                    "type": "media",
16435                    "attrs": {
16436                        "type": "file",
16437                        "id": "abc-123",
16438                        "collection": "contentId-456",
16439                        "height": 100,
16440                        "width": 200,
16441                        "localId": "0e79f58ac382"
16442                    }
16443                }]
16444            }]
16445        });
16446        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16447        let opts = RenderOptions {
16448            strip_local_ids: true,
16449            ..Default::default()
16450        };
16451        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
16452        assert!(
16453            !md.contains("localId="),
16454            "localId should be stripped with strip_local_ids, got: {md}"
16455        );
16456    }
16457
16458    #[test]
16459    fn external_media_localid_roundtrip() {
16460        // localId on external media nodes must also survive round-trip
16461        let adf_doc = serde_json::json!({
16462            "type": "doc",
16463            "version": 1,
16464            "content": [{
16465                "type": "mediaSingle",
16466                "attrs": {"layout": "center"},
16467                "content": [{
16468                    "type": "media",
16469                    "attrs": {
16470                        "type": "external",
16471                        "url": "https://example.com/image.png",
16472                        "alt": "test",
16473                        "localId": "deadbeef1234"
16474                    }
16475                }]
16476            }]
16477        });
16478        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16479        let md = adf_to_markdown(&doc).unwrap();
16480        assert!(
16481            md.contains("localId=deadbeef1234"),
16482            "expected localId in markdown for external media, got: {md}"
16483        );
16484        let doc2 = markdown_to_adf(&md).unwrap();
16485        let media = &doc2.content[0].content.as_ref().unwrap()[0];
16486        let attrs = media.attrs.as_ref().unwrap();
16487        assert_eq!(attrs["localId"], "deadbeef1234");
16488    }
16489
16490    #[test]
16491    fn bracket_in_text_not_parsed_as_link() {
16492        // "[Task] some text (Link)" — the [Task] must NOT be treated as a link anchor
16493        let md = ":check_mark: [Task] Unable to start trial ([Link](https://example.com/link))";
16494        let doc = markdown_to_adf(md).unwrap();
16495        let para = &doc.content[0];
16496        assert_eq!(para.node_type, "paragraph");
16497        let content = para.content.as_ref().unwrap();
16498        // Find the text node containing "[Task]"
16499        let text_nodes: Vec<_> = content.iter().filter(|n| n.node_type == "text").collect();
16500        let has_task_bracket = text_nodes
16501            .iter()
16502            .any(|n| n.text.as_deref().unwrap_or("").contains("[Task]"));
16503        assert!(
16504            has_task_bracket,
16505            "expected [Task] in plain text, nodes: {content:?}"
16506        );
16507        // Also verify the (Link) is a proper link
16508        let link_nodes: Vec<_> = content
16509            .iter()
16510            .filter(|n| {
16511                n.marks
16512                    .as_ref()
16513                    .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "link"))
16514            })
16515            .collect();
16516        assert!(!link_nodes.is_empty(), "expected a link node");
16517        assert_eq!(
16518            link_nodes[0].text.as_deref(),
16519            Some("Link"),
16520            "link text should be 'Link'"
16521        );
16522    }
16523
16524    #[test]
16525    fn empty_paragraph_roundtrip() {
16526        // An empty ADF paragraph node should survive a round-trip through markdown
16527        let mut adf_in = AdfDocument::new();
16528        adf_in.content = vec![
16529            AdfNode::paragraph(vec![AdfNode::text("before")]),
16530            AdfNode::paragraph(vec![]),
16531            AdfNode::paragraph(vec![AdfNode::text("after")]),
16532        ];
16533        let md = adf_to_markdown(&adf_in).unwrap();
16534        let adf_out = markdown_to_adf(&md).unwrap();
16535        assert_eq!(
16536            adf_out.content.len(),
16537            3,
16538            "should have 3 blocks, markdown:\n{md}"
16539        );
16540        assert_eq!(adf_out.content[0].node_type, "paragraph");
16541        assert_eq!(adf_out.content[1].node_type, "paragraph");
16542        assert!(
16543            adf_out.content[1].content.is_none(),
16544            "middle paragraph should be empty"
16545        );
16546        assert_eq!(adf_out.content[2].node_type, "paragraph");
16547    }
16548
16549    #[test]
16550    fn nbsp_paragraph_roundtrip() {
16551        // Issue #411: paragraph with only NBSP should survive round-trip
16552        let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}]}";
16553        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16554        let md = adf_to_markdown(&doc).unwrap();
16555        assert!(
16556            md.contains("::paragraph["),
16557            "NBSP paragraph should use directive form: {md}"
16558        );
16559        let rt = markdown_to_adf(&md).unwrap();
16560        assert_eq!(rt.content.len(), 1, "should have 1 block");
16561        assert_eq!(rt.content[0].node_type, "paragraph");
16562        let text = rt.content[0].content.as_ref().unwrap()[0]
16563            .text
16564            .as_deref()
16565            .unwrap_or("");
16566        assert_eq!(text, "\u{00a0}", "NBSP should survive round-trip");
16567    }
16568
16569    #[test]
16570    fn nbsp_in_nested_expand_roundtrip() {
16571        // Issue #411 real-world case: NBSP paragraph inside nestedExpand
16572        let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"nestedExpand\",\"attrs\":{\"title\":\"Section\"},\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}]}]}";
16573        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16574        let md = adf_to_markdown(&doc).unwrap();
16575        let rt = markdown_to_adf(&md).unwrap();
16576        let ne = &rt.content[0];
16577        assert_eq!(ne.node_type, "nestedExpand");
16578        let inner = ne.content.as_ref().unwrap();
16579        assert_eq!(inner.len(), 1, "should have 1 inner block");
16580        assert_eq!(inner[0].node_type, "paragraph");
16581        let content = inner[0].content.as_ref().unwrap();
16582        assert!(!content.is_empty(), "paragraph should not be empty");
16583        let text = content[0].text.as_deref().unwrap_or("");
16584        assert_eq!(text, "\u{00a0}", "NBSP should survive in nestedExpand");
16585    }
16586
16587    #[test]
16588    fn nbsp_followed_by_content() {
16589        // NBSP paragraph followed by regular content should not interfere
16590        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\"}]}]}";
16591        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16592        let md = adf_to_markdown(&doc).unwrap();
16593        let rt = markdown_to_adf(&md).unwrap();
16594        assert!(rt.content.len() >= 2, "should have at least 2 blocks");
16595        // The second block should be a paragraph with "after"
16596        let after_para = rt.content.iter().find(|n| {
16597            n.node_type == "paragraph"
16598                && n.content
16599                    .as_ref()
16600                    .and_then(|c| c.first())
16601                    .and_then(|n| n.text.as_deref())
16602                    .is_some_and(|t| t.contains("after"))
16603        });
16604        assert!(after_para.is_some(), "should have paragraph with 'after'");
16605    }
16606
16607    #[test]
16608    fn nbsp_paragraph_with_marks_survives() {
16609        // NBSP with bold marks renders as `** **` which contains non-whitespace
16610        // chars and thus doesn't need the directive form — it round-trips naturally
16611        let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\",\"marks\":[{\"type\":\"strong\"}]}]}]}";
16612        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16613        let md = adf_to_markdown(&doc).unwrap();
16614        assert!(md.contains("**"), "should have bold markers: {md}");
16615        let rt = markdown_to_adf(&md).unwrap();
16616        let content = rt.content[0].content.as_ref().unwrap();
16617        assert!(!content.is_empty(), "should preserve content");
16618    }
16619
16620    #[test]
16621    fn regular_paragraph_unchanged() {
16622        // Regression guard: normal paragraphs should NOT use directive form
16623        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}"#;
16624        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16625        let md = adf_to_markdown(&doc).unwrap();
16626        assert!(
16627            !md.contains("::paragraph"),
16628            "regular paragraphs should not use directive form: {md}"
16629        );
16630        assert!(md.contains("hello"));
16631    }
16632
16633    #[test]
16634    fn paragraph_directive_with_content_parsed() {
16635        // ::paragraph[content] should parse to a paragraph with inline nodes
16636        let md = "::paragraph[\u{00a0}]\n";
16637        let doc = markdown_to_adf(md).unwrap();
16638        assert_eq!(doc.content.len(), 1);
16639        assert_eq!(doc.content[0].node_type, "paragraph");
16640        let content = doc.content[0].content.as_ref().unwrap();
16641        assert!(!content.is_empty(), "should have inline content");
16642        assert_eq!(content[0].text.as_deref().unwrap(), "\u{00a0}");
16643    }
16644
16645    #[test]
16646    fn nbsp_paragraph_in_list_item_with_nested_list() {
16647        // Issue #448: NBSP paragraph content lost inside listItem with nested bulletList
16648        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"}]}]}]}]}]}]}"#;
16649        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16650        let md = adf_to_markdown(&doc).unwrap();
16651        let rt = markdown_to_adf(&md).unwrap();
16652        let list = &rt.content[0];
16653        assert_eq!(list.node_type, "bulletList");
16654        let item = &list.content.as_ref().unwrap()[0];
16655        let item_content = item.content.as_ref().unwrap();
16656        assert_eq!(
16657            item_content.len(),
16658            2,
16659            "listItem should have paragraph + nested list, got: {item_content:?}"
16660        );
16661        let para = &item_content[0];
16662        assert_eq!(para.node_type, "paragraph");
16663        let para_content = para
16664            .content
16665            .as_ref()
16666            .expect("paragraph should have content");
16667        assert!(
16668            !para_content.is_empty(),
16669            "NBSP paragraph content should not be empty"
16670        );
16671        assert_eq!(
16672            para_content[0].text.as_deref().unwrap(),
16673            "\u{00a0}",
16674            "NBSP should survive round-trip inside listItem"
16675        );
16676    }
16677
16678    #[test]
16679    fn nbsp_paragraph_in_list_item_with_local_ids() {
16680        // Issue #448: NBSP paragraph with localIds inside listItem with nested list
16681        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"}]}]}]}]}]}]}"#;
16682        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16683        let md = adf_to_markdown(&doc).unwrap();
16684        let rt = markdown_to_adf(&md).unwrap();
16685        let list = &rt.content[0];
16686        let item = &list.content.as_ref().unwrap()[0];
16687        // Check listItem localId
16688        assert_eq!(
16689            item.attrs.as_ref().unwrap()["localId"],
16690            "li-001",
16691            "listItem localId should survive"
16692        );
16693        let item_content = item.content.as_ref().unwrap();
16694        assert_eq!(item_content.len(), 2);
16695        // Check paragraph localId and NBSP content
16696        let para = &item_content[0];
16697        assert_eq!(
16698            para.attrs.as_ref().unwrap()["localId"],
16699            "p-001",
16700            "paragraph localId should survive"
16701        );
16702        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
16703        assert_eq!(text, "\u{00a0}", "NBSP should survive with localIds");
16704    }
16705
16706    #[test]
16707    fn nbsp_paragraph_in_list_item_without_nested_list() {
16708        // NBSP paragraph in a simple listItem (no nested list)
16709        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"}]}]}]}]}"#;
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 para = &item.content.as_ref().unwrap()[0];
16716        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
16717        assert_eq!(text, "\u{00a0}", "NBSP should survive in simple list item");
16718    }
16719
16720    #[test]
16721    fn nbsp_paragraph_in_ordered_list_item_with_nested_list() {
16722        // NBSP paragraph in ordered listItem with nested bulletList
16723        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"}]}]}]}]}]}]}"#;
16724        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16725        let md = adf_to_markdown(&doc).unwrap();
16726        let rt = markdown_to_adf(&md).unwrap();
16727        let list = &rt.content[0];
16728        let item = &list.content.as_ref().unwrap()[0];
16729        let item_content = item.content.as_ref().unwrap();
16730        assert_eq!(item_content.len(), 2);
16731        let para = &item_content[0];
16732        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
16733        assert_eq!(text, "\u{00a0}", "NBSP should survive in ordered list item");
16734    }
16735
16736    #[test]
16737    fn list_item_leading_space_preserved() {
16738        // Leading space in list item text must not be stripped
16739        let md = "- hello world\n- - text";
16740        let doc = markdown_to_adf(md).unwrap();
16741        let list = &doc.content[0];
16742        assert_eq!(list.node_type, "bulletList");
16743        let items = list.content.as_ref().unwrap();
16744        // First item: "hello world" (no leading space, unchanged)
16745        let first_para = &items[0].content.as_ref().unwrap()[0];
16746        let first_text = &first_para.content.as_ref().unwrap()[0];
16747        assert_eq!(first_text.text.as_deref(), Some("hello world"));
16748    }
16749
16750    #[test]
16751    fn list_item_leading_space_not_stripped() {
16752        // When the markdown list item content has a leading space (e.g. " :emoji:"),
16753        // that space must reach parse_inline as-is.
16754        let md = "-  leading space text";
16755        let doc = markdown_to_adf(md).unwrap();
16756        let list = &doc.content[0];
16757        let items = list.content.as_ref().unwrap();
16758        let para = &items[0].content.as_ref().unwrap()[0];
16759        let text_node = &para.content.as_ref().unwrap()[0];
16760        // After "- " (2 chars), trim_end keeps the leading space: " leading space text"
16761        assert_eq!(
16762            text_node.text.as_deref(),
16763            Some(" leading space text"),
16764            "leading space should be preserved"
16765        );
16766    }
16767
16768    // ── Nested container directive tests ───────────────────────────
16769
16770    // ── hardBreak in table cell tests ────────────────────────────
16771
16772    #[test]
16773    fn hardbreak_in_cell_uses_directive_table() {
16774        // A table cell with a hardBreak should NOT use pipe syntax
16775        // because the newline would break the row
16776        let adf = AdfDocument {
16777            version: 1,
16778            doc_type: "doc".to_string(),
16779            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
16780                AdfNode::table_cell(vec![AdfNode::paragraph(vec![
16781                    AdfNode::text("before"),
16782                    AdfNode::hard_break(),
16783                    AdfNode::text("after"),
16784                ])]),
16785            ])])],
16786        };
16787        let md = adf_to_markdown(&adf).unwrap();
16788        // Should render as directive table, not pipe table
16789        assert!(
16790            md.contains(":::td") || md.contains("::::table"),
16791            "Table with hardBreak should use directive form, got:\n{md}"
16792        );
16793        assert!(
16794            !md.contains("| before"),
16795            "Should NOT use pipe syntax with hardBreak"
16796        );
16797    }
16798
16799    #[test]
16800    fn hardbreak_in_cell_roundtrips() {
16801        // Verify the directive table form preserves the hardBreak on round-trip
16802        let adf = AdfDocument {
16803            version: 1,
16804            doc_type: "doc".to_string(),
16805            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
16806                AdfNode::table_cell(vec![AdfNode::paragraph(vec![
16807                    AdfNode::text("line one"),
16808                    AdfNode::hard_break(),
16809                    AdfNode::text("line two"),
16810                ])]),
16811            ])])],
16812        };
16813        let md = adf_to_markdown(&adf).unwrap();
16814        let roundtripped = markdown_to_adf(&md).unwrap();
16815
16816        // Should still have one table with one row with one cell
16817        assert_eq!(roundtripped.content.len(), 1);
16818        assert_eq!(roundtripped.content[0].node_type, "table");
16819        let rows = roundtripped.content[0].content.as_ref().unwrap();
16820        assert_eq!(
16821            rows.len(),
16822            1,
16823            "Should have exactly 1 row, got {}",
16824            rows.len()
16825        );
16826    }
16827
16828    #[test]
16829    fn hardbreak_in_paragraph_roundtrips() {
16830        // Issue #373: hardBreak absorbed into preceding text node
16831        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
16832          {"type":"text","text":"line one"},
16833          {"type":"hardBreak"},
16834          {"type":"text","text":"line two"}
16835        ]}]}"#;
16836        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16837        let md = adf_to_markdown(&doc).unwrap();
16838        let round_tripped = markdown_to_adf(&md).unwrap();
16839        let inlines = round_tripped.content[0].content.as_ref().unwrap();
16840        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
16841        assert_eq!(
16842            types,
16843            vec!["text", "hardBreak", "text"],
16844            "hardBreak should be preserved, got: {types:?}"
16845        );
16846        assert_eq!(inlines[0].text.as_deref(), Some("line one"));
16847        assert_eq!(inlines[2].text.as_deref(), Some("line two"));
16848    }
16849
16850    #[test]
16851    fn consecutive_hardbreaks_in_paragraph_roundtrip() {
16852        // Issue #410: consecutive hardBreak nodes collapsed on round-trip
16853        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
16854          {"type":"text","text":"before"},
16855          {"type":"hardBreak"},
16856          {"type":"hardBreak"},
16857          {"type":"text","text":"after"}
16858        ]}]}"#;
16859        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16860        let md = adf_to_markdown(&doc).unwrap();
16861        let round_tripped = markdown_to_adf(&md).unwrap();
16862        assert_eq!(
16863            round_tripped.content.len(),
16864            1,
16865            "Should remain a single paragraph, got {} blocks",
16866            round_tripped.content.len()
16867        );
16868        let inlines = round_tripped.content[0].content.as_ref().unwrap();
16869        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
16870        assert_eq!(
16871            types,
16872            vec!["text", "hardBreak", "hardBreak", "text"],
16873            "Both hardBreaks should be preserved, got: {types:?}"
16874        );
16875        assert_eq!(inlines[0].text.as_deref(), Some("before"));
16876        assert_eq!(inlines[3].text.as_deref(), Some("after"));
16877    }
16878
16879    #[test]
16880    fn hardbreak_only_paragraph_roundtrips() {
16881        // Issue #410: paragraph whose only content is a hardBreak is dropped
16882        let adf_json = r#"{"version":1,"type":"doc","content":[
16883          {"type":"paragraph","content":[{"type":"hardBreak"}]}
16884        ]}"#;
16885        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16886        let md = adf_to_markdown(&doc).unwrap();
16887        let round_tripped = markdown_to_adf(&md).unwrap();
16888        assert_eq!(
16889            round_tripped.content.len(),
16890            1,
16891            "Paragraph should not be dropped, got {} blocks",
16892            round_tripped.content.len()
16893        );
16894        let inlines = round_tripped.content[0].content.as_ref().unwrap();
16895        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
16896        assert_eq!(
16897            types,
16898            vec!["hardBreak"],
16899            "hardBreak-only paragraph should preserve its content, got: {types:?}"
16900        );
16901    }
16902
16903    #[test]
16904    fn issue_410_full_reproducer_roundtrips() {
16905        // Full reproducer from issue #410: consecutive hardBreaks + hardBreak-only paragraph
16906        let adf_json = r#"{"version":1,"type":"doc","content":[
16907          {"type":"paragraph","content":[
16908            {"type":"text","text":"before"},
16909            {"type":"hardBreak"},
16910            {"type":"hardBreak"},
16911            {"type":"text","text":"after"}
16912          ]},
16913          {"type":"paragraph","content":[
16914            {"type":"hardBreak"}
16915          ]}
16916        ]}"#;
16917        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16918        let md = adf_to_markdown(&doc).unwrap();
16919        let round_tripped = markdown_to_adf(&md).unwrap();
16920        assert_eq!(
16921            round_tripped.content.len(),
16922            2,
16923            "Should have exactly 2 paragraphs, got {}",
16924            round_tripped.content.len()
16925        );
16926        // First paragraph: text, hardBreak, hardBreak, text
16927        let p1 = round_tripped.content[0].content.as_ref().unwrap();
16928        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
16929        assert_eq!(types1, vec!["text", "hardBreak", "hardBreak", "text"]);
16930        // Second paragraph: hardBreak only
16931        let p2 = round_tripped.content[1].content.as_ref().unwrap();
16932        let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
16933        assert_eq!(types2, vec!["hardBreak"]);
16934    }
16935
16936    #[test]
16937    fn trailing_space_hardbreak_still_parsed() {
16938        // Backward compatibility: trailing-space hardBreak (old JFM format) still parses
16939        let md = "line one  \nline two\n";
16940        let doc = markdown_to_adf(md).unwrap();
16941        let inlines = doc.content[0].content.as_ref().unwrap();
16942        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
16943        assert_eq!(
16944            types,
16945            vec!["text", "hardBreak", "text"],
16946            "Trailing-space hardBreak should still parse, got: {types:?}"
16947        );
16948    }
16949
16950    #[test]
16951    fn trailing_hardbreak_at_end_of_paragraph_roundtrips() {
16952        // A paragraph ending with a hardBreak (no text after it)
16953        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
16954          {"type":"text","text":"text"},
16955          {"type":"hardBreak"}
16956        ]}]}"#;
16957        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16958        let md = adf_to_markdown(&doc).unwrap();
16959        let round_tripped = markdown_to_adf(&md).unwrap();
16960        let inlines = round_tripped.content[0].content.as_ref().unwrap();
16961        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
16962        assert_eq!(
16963            types,
16964            vec!["text", "hardBreak"],
16965            "Trailing hardBreak should be preserved, got: {types:?}"
16966        );
16967    }
16968
16969    #[test]
16970    #[test]
16971    fn table_with_header_row_uses_pipe_syntax() {
16972        // A table with tableHeader in the first row should use pipe syntax
16973        let adf = AdfDocument {
16974            version: 1,
16975            doc_type: "doc".to_string(),
16976            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
16977                AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("header cell")])]),
16978            ])])],
16979        };
16980        let md = adf_to_markdown(&adf).unwrap();
16981        assert!(
16982            md.contains("| header cell |"),
16983            "Table with header row should use pipe syntax, got:\n{md}"
16984        );
16985    }
16986
16987    #[test]
16988    fn table_without_header_row_uses_directive_syntax() {
16989        // Issue #392: tableCell-only first row must use directive syntax
16990        // to avoid converting tableCell → tableHeader on round-trip
16991        let adf = AdfDocument {
16992            version: 1,
16993            doc_type: "doc".to_string(),
16994            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
16995                AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("simple cell")])]),
16996            ])])],
16997        };
16998        let md = adf_to_markdown(&adf).unwrap();
16999        assert!(
17000            md.contains("::::table"),
17001            "Table without header row should use directive syntax, got:\n{md}"
17002        );
17003    }
17004
17005    #[test]
17006    fn tablecell_first_row_preserved_on_roundtrip() {
17007        // Issue #392: tableCell in first row round-trips as tableHeader
17008        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{},"content":[
17009          {"type":"tableRow","content":[
17010            {"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"row1 cell"}]}]}
17011          ]},
17012          {"type":"tableRow","content":[
17013            {"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"row2 cell"}]}]}
17014          ]}
17015        ]}]}"#;
17016        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17017        let md = adf_to_markdown(&doc).unwrap();
17018        let round_tripped = markdown_to_adf(&md).unwrap();
17019        let rows = round_tripped.content[0].content.as_ref().unwrap();
17020        let row0_cell = &rows[0].content.as_ref().unwrap()[0];
17021        assert_eq!(
17022            row0_cell.node_type, "tableCell",
17023            "first row cell should remain tableCell, got: {}",
17024            row0_cell.node_type
17025        );
17026        let row1_cell = &rows[1].content.as_ref().unwrap()[0];
17027        assert_eq!(row1_cell.node_type, "tableCell");
17028    }
17029
17030    #[test]
17031    fn mixed_header_and_cell_first_row_uses_pipe() {
17032        // A first row with at least one tableHeader qualifies for pipe syntax
17033        let adf = AdfDocument {
17034            version: 1,
17035            doc_type: "doc".to_string(),
17036            content: vec![AdfNode::table(vec![
17037                AdfNode::table_row(vec![
17038                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
17039                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
17040                ]),
17041                AdfNode::table_row(vec![
17042                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C1")])]),
17043                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
17044                ]),
17045            ])],
17046        };
17047        let md = adf_to_markdown(&adf).unwrap();
17048        assert!(
17049            md.contains("| H1 |"),
17050            "Table with header first row should use pipe syntax, got:\n{md}"
17051        );
17052        assert!(!md.contains("::::table"), "should not use directive syntax");
17053    }
17054
17055    // ── Issue #579: pipes in pipe-table cells ─────────────────────
17056
17057    #[test]
17058    fn render_pipe_table_escapes_pipe_in_code_span_cell() {
17059        // A code-marked text node with a literal `|` in a pipe-table cell
17060        // must emit `\|` so the column separator is unambiguous.
17061        let adf = AdfDocument {
17062            version: 1,
17063            doc_type: "doc".to_string(),
17064            content: vec![AdfNode::table(vec![
17065                AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
17066                    AdfNode::text("Header"),
17067                ])])]),
17068                AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
17069                    AdfNode::text_with_marks("a|b", vec![AdfMark::code()]),
17070                ])])]),
17071            ])],
17072        };
17073        let md = adf_to_markdown(&adf).unwrap();
17074        assert!(
17075            md.contains(r"`a\|b`"),
17076            "Pipe inside code span must be escaped, got:\n{md}"
17077        );
17078    }
17079
17080    #[test]
17081    fn render_pipe_table_escapes_pipe_in_plain_text_cell() {
17082        let adf = AdfDocument {
17083            version: 1,
17084            doc_type: "doc".to_string(),
17085            content: vec![AdfNode::table(vec![
17086                AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
17087                    AdfNode::text("Header"),
17088                ])])]),
17089                AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
17090                    AdfNode::text("x|y"),
17091                ])])]),
17092            ])],
17093        };
17094        let md = adf_to_markdown(&adf).unwrap();
17095        assert!(
17096            md.contains(r"x\|y"),
17097            "Pipe inside plain-text cell must be escaped, got:\n{md}"
17098        );
17099    }
17100
17101    #[test]
17102    fn code_span_with_pipe_in_table_cell_roundtrips() {
17103        // Issue #579 reproducer: code span containing `|` in a pipe-table cell.
17104        let adf_json = r#"{
17105            "version": 1,
17106            "type": "doc",
17107            "content": [{
17108                "type": "table",
17109                "attrs": {"isNumberColumnEnabled": false, "layout": "default", "localId": "abc-789"},
17110                "content": [
17111                    {"type": "tableRow", "content": [
17112                        {"type": "tableHeader", "attrs": {}, "content": [
17113                            {"type": "paragraph", "content": [{"type": "text", "text": "Before"}]}
17114                        ]},
17115                        {"type": "tableHeader", "attrs": {}, "content": [
17116                            {"type": "paragraph", "content": [{"type": "text", "text": "After"}]}
17117                        ]}
17118                    ]},
17119                    {"type": "tableRow", "content": [
17120                        {"type": "tableCell", "attrs": {}, "content": [
17121                            {"type": "paragraph", "content": [
17122                                {"type": "text", "text": "parse(json).extract[T]", "marks": [{"type": "code"}]}
17123                            ]}
17124                        ]},
17125                        {"type": "tableCell", "attrs": {}, "content": [
17126                            {"type": "paragraph", "content": [
17127                                {"type": "text", "text": "parser.decode[T|json]", "marks": [{"type": "code"}]}
17128                            ]}
17129                        ]}
17130                    ]}
17131                ]
17132            }]
17133        }"#;
17134        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17135        let md = adf_to_markdown(&doc).unwrap();
17136        let round_tripped = markdown_to_adf(&md).unwrap();
17137
17138        let rows = round_tripped.content[0].content.as_ref().unwrap();
17139        assert_eq!(
17140            rows.len(),
17141            2,
17142            "Table should have 2 rows, got: {}",
17143            rows.len()
17144        );
17145
17146        let body_row = rows[1].content.as_ref().unwrap();
17147        assert_eq!(
17148            body_row.len(),
17149            2,
17150            "Body row should have 2 cells (not split by the pipe), got: {}",
17151            body_row.len()
17152        );
17153
17154        let second_cell = &body_row[1];
17155        let para = second_cell.content.as_ref().unwrap().first().unwrap();
17156        let inlines = para.content.as_ref().unwrap();
17157        assert_eq!(inlines.len(), 1, "Cell should have a single text node");
17158        assert_eq!(
17159            inlines[0].text.as_deref(),
17160            Some("parser.decode[T|json]"),
17161            "Code-span text must be preserved with literal pipe"
17162        );
17163        let marks = inlines[0]
17164            .marks
17165            .as_ref()
17166            .expect("code mark must be preserved");
17167        assert!(
17168            marks.iter().any(|m| m.mark_type == "code"),
17169            "text node should carry the code mark"
17170        );
17171    }
17172
17173    #[test]
17174    fn plain_text_pipe_in_table_cell_roundtrips() {
17175        // Plain text with `|` in a pipe-table cell should also survive.
17176        let adf = AdfDocument {
17177            version: 1,
17178            doc_type: "doc".to_string(),
17179            content: vec![AdfNode::table(vec![
17180                AdfNode::table_row(vec![
17181                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
17182                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
17183                ]),
17184                AdfNode::table_row(vec![
17185                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("a|b")])]),
17186                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("c")])]),
17187                ]),
17188            ])],
17189        };
17190        let md = adf_to_markdown(&adf).unwrap();
17191        let round_tripped = markdown_to_adf(&md).unwrap();
17192        let rows = round_tripped.content[0].content.as_ref().unwrap();
17193        let body_row = rows[1].content.as_ref().unwrap();
17194        assert_eq!(
17195            body_row.len(),
17196            2,
17197            "Body row should keep 2 cells, got: {}",
17198            body_row.len()
17199        );
17200        let first_cell_text = body_row[0].content.as_ref().unwrap()[0]
17201            .content
17202            .as_ref()
17203            .unwrap()[0]
17204            .text
17205            .as_deref();
17206        assert_eq!(first_cell_text, Some("a|b"));
17207    }
17208
17209    #[test]
17210    fn cell_contains_hard_break_true() {
17211        let para = AdfNode::paragraph(vec![
17212            AdfNode::text("a"),
17213            AdfNode::hard_break(),
17214            AdfNode::text("b"),
17215        ]);
17216        assert!(cell_contains_hard_break(&para));
17217    }
17218
17219    #[test]
17220    fn cell_contains_hard_break_false() {
17221        let para = AdfNode::paragraph(vec![AdfNode::text("no break here")]);
17222        assert!(!cell_contains_hard_break(&para));
17223    }
17224
17225    #[test]
17226    fn cell_contains_hard_break_empty() {
17227        let para = AdfNode::paragraph(vec![]);
17228        assert!(!cell_contains_hard_break(&para));
17229    }
17230
17231    // ── Multi-paragraph container tests ──────────────────────────
17232
17233    #[test]
17234    fn multi_paragraph_panel_roundtrips() {
17235        let adf = AdfDocument {
17236            version: 1,
17237            doc_type: "doc".to_string(),
17238            content: vec![AdfNode {
17239                node_type: "panel".to_string(),
17240                attrs: Some(serde_json::json!({"panelType": "info"})),
17241                content: Some(vec![
17242                    AdfNode::paragraph(vec![AdfNode::text("First paragraph.")]),
17243                    AdfNode::paragraph(vec![AdfNode::text("Second paragraph.")]),
17244                ]),
17245                text: None,
17246                marks: None,
17247                local_id: None,
17248                parameters: None,
17249            }],
17250        };
17251
17252        let md = adf_to_markdown(&adf).unwrap();
17253        // Should have blank line between paragraphs inside the panel
17254        assert!(
17255            md.contains("First paragraph.\n\nSecond paragraph."),
17256            "Panel should have blank line between paragraphs, got:\n{md}"
17257        );
17258
17259        // Round-trip should preserve two separate paragraphs
17260        let roundtripped = markdown_to_adf(&md).unwrap();
17261        assert_eq!(roundtripped.content.len(), 1);
17262        assert_eq!(roundtripped.content[0].node_type, "panel");
17263        let panel_content = roundtripped.content[0].content.as_ref().unwrap();
17264        assert_eq!(
17265            panel_content.len(),
17266            2,
17267            "Panel should have 2 paragraphs after round-trip, got {}",
17268            panel_content.len()
17269        );
17270    }
17271
17272    #[test]
17273    fn multi_paragraph_expand_roundtrips() {
17274        let adf = AdfDocument {
17275            version: 1,
17276            doc_type: "doc".to_string(),
17277            content: vec![AdfNode {
17278                node_type: "expand".to_string(),
17279                attrs: Some(serde_json::json!({"title": "Details"})),
17280                content: Some(vec![
17281                    AdfNode::paragraph(vec![AdfNode::text("Para one.")]),
17282                    AdfNode::paragraph(vec![AdfNode::text("Para two.")]),
17283                ]),
17284                text: None,
17285                marks: None,
17286                local_id: None,
17287                parameters: None,
17288            }],
17289        };
17290
17291        let md = adf_to_markdown(&adf).unwrap();
17292        let roundtripped = markdown_to_adf(&md).unwrap();
17293        let expand_content = roundtripped.content[0].content.as_ref().unwrap();
17294        assert_eq!(
17295            expand_content.len(),
17296            2,
17297            "Expand should have 2 paragraphs after round-trip, got {}",
17298            expand_content.len()
17299        );
17300    }
17301
17302    #[test]
17303    fn consecutive_nested_expands_in_table_cell_roundtrip() {
17304        let cell_content = vec![
17305            AdfNode {
17306                node_type: "nestedExpand".to_string(),
17307                attrs: Some(serde_json::json!({"title": "First"})),
17308                content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("item 1")])]),
17309                text: None,
17310                marks: None,
17311                local_id: None,
17312                parameters: None,
17313            },
17314            AdfNode {
17315                node_type: "nestedExpand".to_string(),
17316                attrs: Some(serde_json::json!({"title": "Second"})),
17317                content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("item 2")])]),
17318                text: None,
17319                marks: None,
17320                local_id: None,
17321                parameters: None,
17322            },
17323        ];
17324        let adf = AdfDocument {
17325            version: 1,
17326            doc_type: "doc".to_string(),
17327            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17328                AdfNode::table_cell(cell_content),
17329            ])])],
17330        };
17331
17332        let md = adf_to_markdown(&adf).unwrap();
17333        assert!(
17334            md.contains(":::\n\n:::nested-expand"),
17335            "Should have blank line between consecutive nested-expands in cell, got:\n{md}"
17336        );
17337
17338        let rt = markdown_to_adf(&md).unwrap();
17339        let cell = &rt.content[0].content.as_ref().unwrap()[0]
17340            .content
17341            .as_ref()
17342            .unwrap()[0];
17343        let cell_nodes = cell.content.as_ref().unwrap();
17344        let expand_count = cell_nodes
17345            .iter()
17346            .filter(|n| n.node_type == "nestedExpand")
17347            .count();
17348        assert_eq!(
17349            expand_count, 2,
17350            "Both nested-expands should survive round-trip, got {expand_count}"
17351        );
17352    }
17353
17354    #[test]
17355    fn multi_paragraph_in_table_cell_roundtrip() {
17356        // Two paragraphs inside a directive table cell should survive round-trip
17357        let adf = AdfDocument {
17358            version: 1,
17359            doc_type: "doc".to_string(),
17360            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17361                AdfNode::table_cell(vec![
17362                    AdfNode::paragraph(vec![AdfNode::text("Para one.")]),
17363                    AdfNode::paragraph(vec![AdfNode::text("Para two.")]),
17364                ]),
17365            ])])],
17366        };
17367
17368        let md = adf_to_markdown(&adf).unwrap();
17369        assert!(
17370            md.contains("Para one.\n\nPara two."),
17371            "Should have blank line between paragraphs in cell, got:\n{md}"
17372        );
17373
17374        let rt = markdown_to_adf(&md).unwrap();
17375        let cell = &rt.content[0].content.as_ref().unwrap()[0]
17376            .content
17377            .as_ref()
17378            .unwrap()[0];
17379        let para_count = cell
17380            .content
17381            .as_ref()
17382            .unwrap()
17383            .iter()
17384            .filter(|n| n.node_type == "paragraph")
17385            .count();
17386        assert_eq!(para_count, 2, "Both paragraphs should survive round-trip");
17387    }
17388
17389    #[test]
17390    fn panel_inside_table_cell_roundtrip() {
17391        // A panel inside a directive table cell
17392        let adf = AdfDocument {
17393            version: 1,
17394            doc_type: "doc".to_string(),
17395            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17396                AdfNode::table_cell(vec![
17397                    AdfNode::paragraph(vec![AdfNode::text("Before panel.")]),
17398                    AdfNode {
17399                        node_type: "panel".to_string(),
17400                        attrs: Some(serde_json::json!({"panelType": "info"})),
17401                        content: Some(vec![AdfNode::paragraph(vec![AdfNode::text(
17402                            "Panel content",
17403                        )])]),
17404                        text: None,
17405                        marks: None,
17406                        local_id: None,
17407                        parameters: None,
17408                    },
17409                ]),
17410            ])])],
17411        };
17412
17413        let md = adf_to_markdown(&adf).unwrap();
17414        assert!(
17415            md.contains(":::panel"),
17416            "Should contain panel directive, got:\n{md}"
17417        );
17418
17419        let rt = markdown_to_adf(&md).unwrap();
17420        let cell = &rt.content[0].content.as_ref().unwrap()[0]
17421            .content
17422            .as_ref()
17423            .unwrap()[0];
17424        let has_panel = cell
17425            .content
17426            .as_ref()
17427            .unwrap()
17428            .iter()
17429            .any(|n| n.node_type == "panel");
17430        assert!(has_panel, "Panel should survive round-trip in table cell");
17431    }
17432
17433    #[test]
17434    fn three_consecutive_expands_in_table_cell() {
17435        let make_expand = |title: &str| AdfNode {
17436            node_type: "nestedExpand".to_string(),
17437            attrs: Some(serde_json::json!({"title": title})),
17438            content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("content")])]),
17439            text: None,
17440            marks: None,
17441            local_id: None,
17442            parameters: None,
17443        };
17444        let adf = AdfDocument {
17445            version: 1,
17446            doc_type: "doc".to_string(),
17447            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17448                AdfNode::table_cell(vec![
17449                    make_expand("First"),
17450                    make_expand("Second"),
17451                    make_expand("Third"),
17452                ]),
17453            ])])],
17454        };
17455
17456        let md = adf_to_markdown(&adf).unwrap();
17457        let rt = markdown_to_adf(&md).unwrap();
17458        let cell = &rt.content[0].content.as_ref().unwrap()[0]
17459            .content
17460            .as_ref()
17461            .unwrap()[0];
17462        let expand_count = cell
17463            .content
17464            .as_ref()
17465            .unwrap()
17466            .iter()
17467            .filter(|n| n.node_type == "nestedExpand")
17468            .count();
17469        assert_eq!(expand_count, 3, "All 3 expands should survive round-trip");
17470    }
17471
17472    // ── Nested container directive tests ───────────────────────────
17473
17474    #[test]
17475    fn nested_expand_inside_panel() {
17476        // Issue #714: the converter still produces panel→expand at the AST
17477        // level (this is what users may type), but the document fails ADF
17478        // schema validation, surfacing an actionable error before the API
17479        // call rather than an opaque Confluence HTTP 500.
17480        let md = ":::panel{type=info}\n:::expand{title=\"Details\"}\nHidden content\n:::\nMore panel content\n:::";
17481        let adf = markdown_to_adf(md).unwrap();
17482
17483        let err = crate::atlassian::adf_validated::validate(&adf).unwrap_err();
17484        assert!(err.violations.iter().any(|v| matches!(
17485            v,
17486            crate::atlassian::adf_schema::AdfSchemaViolation::DisallowedChild {
17487                parent_type, child_type, ..
17488            } if parent_type == "panel" && child_type == "expand"
17489        )));
17490    }
17491
17492    #[test]
17493    fn nested_expand_inside_table_cell() {
17494        // Issue #714: tableCell → expand is a Confluence content-model
17495        // violation. Table cells require `nestedExpand` instead. Validation
17496        // catches this before the API call.
17497        let md = "::::table\n:::tr\n:::td\n:::expand{title=\"Details\"}\nExpand content\n:::\n:::\n:::\n::::";
17498        let adf = markdown_to_adf(md).unwrap();
17499
17500        let err = crate::atlassian::adf_validated::validate(&adf).unwrap_err();
17501        assert!(err.violations.iter().any(|v| matches!(
17502            v,
17503            crate::atlassian::adf_schema::AdfSchemaViolation::DisallowedChild {
17504                parent_type, child_type, ..
17505            } if parent_type == "tableCell" && child_type == "expand"
17506        )));
17507    }
17508
17509    #[test]
17510    fn nested_expand_inside_layout_column() {
17511        // Issue #714 sanity check: `expand` inside a `layoutColumn` is
17512        // legitimate per the ADF schema and must NOT trigger validation.
17513        // Note: layoutSection requires 2..=3 columns per #733's quantifier
17514        // checks, so the markdown declares two columns.
17515        let md = ":::layout\n:::column{width=50}\n:::expand{title=\"Col Expand\"}\nExpanded\n:::\n:::\n:::column{width=50}\nFiller paragraph.\n:::\n:::";
17516        let adf = markdown_to_adf(md).unwrap();
17517
17518        assert_eq!(adf.content.len(), 1);
17519        assert_eq!(adf.content[0].node_type, "layoutSection");
17520
17521        let columns = adf.content[0].content.as_ref().unwrap();
17522        assert_eq!(columns.len(), 2);
17523        let col_content = columns[0].content.as_ref().unwrap();
17524        assert!(
17525            col_content.iter().any(|n| n.node_type == "expand"),
17526            "Column should contain an expand node, got: {:?}",
17527            col_content.iter().map(|n| &n.node_type).collect::<Vec<_>>()
17528        );
17529
17530        // Validation must not flag this legitimate nesting.
17531        crate::atlassian::adf_validated::validate(&adf).unwrap();
17532    }
17533
17534    #[test]
17535    fn expand_localid_in_directive_attrs() {
17536        // Issue #412: localId should be in directive attrs, not trailing text
17537        let adf_json = r#"{"version":1,"type":"doc","content":[
17538          {"type":"expand","attrs":{"localId":"exp-001","title":"Details"},"content":[
17539            {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17540          ]}
17541        ]}"#;
17542        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17543        let md = adf_to_markdown(&doc).unwrap();
17544        assert!(
17545            md.contains("localId=exp-001"),
17546            "should contain localId: {md}"
17547        );
17548        assert!(
17549            md.contains(":::expand{"),
17550            "should have expand directive with attrs: {md}"
17551        );
17552        assert!(
17553            !md.contains(":::\n{localId="),
17554            "localId should NOT be trailing: {md}"
17555        );
17556    }
17557
17558    #[test]
17559    fn expand_localid_roundtrip() {
17560        let adf_json = r#"{"version":1,"type":"doc","content":[
17561          {"type":"expand","attrs":{"localId":"exp-001","title":"Details"},"content":[
17562            {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17563          ]}
17564        ]}"#;
17565        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17566        let md = adf_to_markdown(&doc).unwrap();
17567        let rt = markdown_to_adf(&md).unwrap();
17568        let expand = &rt.content[0];
17569        assert_eq!(expand.node_type, "expand");
17570        assert_eq!(
17571            expand.local_id.as_deref(),
17572            Some("exp-001"),
17573            "expand localId should survive round-trip"
17574        );
17575        assert_eq!(
17576            expand.attrs.as_ref().unwrap()["title"],
17577            "Details",
17578            "expand title should survive round-trip"
17579        );
17580    }
17581
17582    #[test]
17583    fn nested_expand_localid_roundtrip() {
17584        let adf_json = r#"{"version":1,"type":"doc","content":[
17585          {"type":"nestedExpand","attrs":{"localId":"ne-001","title":"S"},"content":[
17586            {"type":"paragraph","content":[{"type":"text","text":"content"}]}
17587          ]}
17588        ]}"#;
17589        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17590        let md = adf_to_markdown(&doc).unwrap();
17591        assert!(
17592            md.contains(":::nested-expand{"),
17593            "should have directive: {md}"
17594        );
17595        assert!(md.contains("localId=ne-001"), "should have localId: {md}");
17596        let rt = markdown_to_adf(&md).unwrap();
17597        let ne = &rt.content[0];
17598        assert_eq!(ne.node_type, "nestedExpand");
17599        assert_eq!(ne.local_id.as_deref(), Some("ne-001"));
17600    }
17601
17602    #[test]
17603    fn nested_expand_localid_followed_by_content() {
17604        // Issue #412 reproducer: localId must not leak into following paragraph
17605        let adf_json = "{\
17606            \"version\":1,\"type\":\"doc\",\"content\":[\
17607              {\"type\":\"nestedExpand\",\"attrs\":{\"localId\":\"exp-001\",\"title\":\"S\"},\"content\":[\
17608                {\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}\
17609              ]},\
17610              {\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"after\"}]}\
17611            ]}";
17612        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17613        let md = adf_to_markdown(&doc).unwrap();
17614        let rt = markdown_to_adf(&md).unwrap();
17615        // nestedExpand should have localId
17616        let ne = &rt.content[0];
17617        assert_eq!(ne.node_type, "nestedExpand");
17618        assert_eq!(
17619            ne.local_id.as_deref(),
17620            Some("exp-001"),
17621            "nestedExpand should preserve localId"
17622        );
17623        // Following paragraph should contain "after", not "{localId=...}"
17624        let para = &rt.content[1];
17625        assert_eq!(para.node_type, "paragraph");
17626        let text = para.content.as_ref().unwrap()[0]
17627            .text
17628            .as_deref()
17629            .unwrap_or("");
17630        assert!(
17631            !text.contains("localId"),
17632            "following paragraph should not contain localId: {text}"
17633        );
17634        assert!(
17635            text.contains("after"),
17636            "following paragraph should contain 'after': {text}"
17637        );
17638    }
17639
17640    #[test]
17641    fn expand_localid_without_title() {
17642        let adf_json = r#"{"version":1,"type":"doc","content":[
17643          {"type":"expand","attrs":{"localId":"exp-002"},"content":[
17644            {"type":"paragraph","content":[{"type":"text","text":"no title"}]}
17645          ]}
17646        ]}"#;
17647        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17648        let md = adf_to_markdown(&doc).unwrap();
17649        assert!(
17650            md.contains(":::expand{localId=exp-002}"),
17651            "should have localId without title: {md}"
17652        );
17653        let rt = markdown_to_adf(&md).unwrap();
17654        assert_eq!(rt.content[0].local_id.as_deref(), Some("exp-002"));
17655    }
17656
17657    #[test]
17658    fn expand_localid_stripped() {
17659        let adf_json = r#"{"version":1,"type":"doc","content":[
17660          {"type":"expand","attrs":{"localId":"exp-001","title":"X"},"content":[
17661            {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17662          ]}
17663        ]}"#;
17664        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17665        let opts = RenderOptions {
17666            strip_local_ids: true,
17667        };
17668        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
17669        assert!(!md.contains("localId"), "localId should be stripped: {md}");
17670        assert!(
17671            md.contains(":::expand{title=\"X\"}"),
17672            "title should remain: {md}"
17673        );
17674    }
17675
17676    // ── Issue #444: top-level localId and parameters on expand ──
17677
17678    #[test]
17679    fn expand_top_level_localid_roundtrip() {
17680        // localId as a top-level field (not inside attrs) should survive round-trip
17681        let adf_json = r#"{"version":1,"type":"doc","content":[
17682          {"type":"expand","attrs":{"title":"My Section"},"localId":"abc-123","content":[
17683            {"type":"paragraph","content":[{"type":"text","text":"hello"}]}
17684          ]}
17685        ]}"#;
17686        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17687        assert_eq!(doc.content[0].local_id.as_deref(), Some("abc-123"));
17688        let md = adf_to_markdown(&doc).unwrap();
17689        assert!(
17690            md.contains("localId=abc-123"),
17691            "JFM should contain localId: {md}"
17692        );
17693        let rt = markdown_to_adf(&md).unwrap();
17694        let expand = &rt.content[0];
17695        assert_eq!(expand.node_type, "expand");
17696        assert_eq!(expand.local_id.as_deref(), Some("abc-123"));
17697        assert_eq!(
17698            expand.attrs.as_ref().unwrap()["title"],
17699            "My Section",
17700            "title should survive round-trip"
17701        );
17702    }
17703
17704    #[test]
17705    fn expand_parameters_roundtrip() {
17706        // parameters (macroMetadata) should survive round-trip
17707        let adf_json = r#"{"version":1,"type":"doc","content":[
17708          {"type":"expand","attrs":{"title":"Props"},"parameters":{"macroMetadata":{"macroId":{"value":"m-001"},"schemaVersion":{"value":"1"}}},"content":[
17709            {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17710          ]}
17711        ]}"#;
17712        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17713        assert!(doc.content[0].parameters.is_some());
17714        let md = adf_to_markdown(&doc).unwrap();
17715        assert!(md.contains("params="), "JFM should contain params: {md}");
17716        let rt = markdown_to_adf(&md).unwrap();
17717        let expand = &rt.content[0];
17718        let params = expand
17719            .parameters
17720            .as_ref()
17721            .expect("parameters should survive round-trip");
17722        assert_eq!(params["macroMetadata"]["macroId"]["value"], "m-001");
17723        assert_eq!(params["macroMetadata"]["schemaVersion"]["value"], "1");
17724    }
17725
17726    #[test]
17727    fn expand_localid_and_parameters_roundtrip() {
17728        // Issue #444: both localId and parameters on expand should survive round-trip
17729        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"}]}]}]}"#;
17730        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17731        let md = adf_to_markdown(&doc).unwrap();
17732        let rt = markdown_to_adf(&md).unwrap();
17733        let expand = &rt.content[0];
17734        assert_eq!(expand.node_type, "expand");
17735        assert_eq!(expand.local_id.as_deref(), Some("abc-123"));
17736        assert_eq!(expand.attrs.as_ref().unwrap()["title"], "My Section");
17737        let params = expand
17738            .parameters
17739            .as_ref()
17740            .expect("parameters should survive");
17741        assert_eq!(params["macroMetadata"]["macroId"]["value"], "macro-001");
17742        assert_eq!(params["macroMetadata"]["title"], "Page Properties");
17743    }
17744
17745    #[test]
17746    fn nested_expand_top_level_localid_and_parameters_roundtrip() {
17747        let adf_json = r#"{"version":1,"type":"doc","content":[
17748          {"type":"nestedExpand","attrs":{"title":"Nested"},"localId":"ne-100","parameters":{"macroMetadata":{"macroId":{"value":"nm-001"}}},"content":[
17749            {"type":"paragraph","content":[{"type":"text","text":"inner"}]}
17750          ]}
17751        ]}"#;
17752        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17753        let md = adf_to_markdown(&doc).unwrap();
17754        assert!(
17755            md.contains(":::nested-expand{"),
17756            "should use nested-expand: {md}"
17757        );
17758        assert!(md.contains("localId=ne-100"), "should have localId: {md}");
17759        assert!(md.contains("params="), "should have params: {md}");
17760        let rt = markdown_to_adf(&md).unwrap();
17761        let ne = &rt.content[0];
17762        assert_eq!(ne.node_type, "nestedExpand");
17763        assert_eq!(ne.local_id.as_deref(), Some("ne-100"));
17764        assert_eq!(
17765            ne.parameters.as_ref().unwrap()["macroMetadata"]["macroId"]["value"],
17766            "nm-001"
17767        );
17768    }
17769
17770    #[test]
17771    fn expand_top_level_localid_stripped() {
17772        // strip_local_ids should strip top-level localId too
17773        let adf_json = r#"{"version":1,"type":"doc","content":[
17774          {"type":"expand","attrs":{"title":"X"},"localId":"exp-strip","content":[
17775            {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17776          ]}
17777        ]}"#;
17778        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17779        let opts = RenderOptions {
17780            strip_local_ids: true,
17781        };
17782        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
17783        assert!(!md.contains("localId"), "localId should be stripped: {md}");
17784        assert!(
17785            md.contains(":::expand{title=\"X\"}"),
17786            "title should remain: {md}"
17787        );
17788    }
17789
17790    #[test]
17791    fn expand_parameters_without_localid() {
17792        // parameters without localId should work
17793        let adf_json = r#"{"version":1,"type":"doc","content":[
17794          {"type":"expand","attrs":{"title":"P"},"parameters":{"macroMetadata":{"macroId":{"value":"solo"}}},"content":[
17795            {"type":"paragraph","content":[{"type":"text","text":"data"}]}
17796          ]}
17797        ]}"#;
17798        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17799        let md = adf_to_markdown(&doc).unwrap();
17800        assert!(!md.contains("localId"), "no localId: {md}");
17801        assert!(md.contains("params="), "has params: {md}");
17802        let rt = markdown_to_adf(&md).unwrap();
17803        assert!(rt.content[0].local_id.is_none());
17804        assert_eq!(
17805            rt.content[0].parameters.as_ref().unwrap()["macroMetadata"]["macroId"]["value"],
17806            "solo"
17807        );
17808    }
17809
17810    #[test]
17811    fn expand_localid_without_parameters() {
17812        // top-level localId without parameters should work
17813        let adf_json = r#"{"version":1,"type":"doc","content":[
17814          {"type":"expand","attrs":{"title":"L"},"localId":"lid-only","content":[
17815            {"type":"paragraph","content":[{"type":"text","text":"txt"}]}
17816          ]}
17817        ]}"#;
17818        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17819        let md = adf_to_markdown(&doc).unwrap();
17820        assert!(md.contains("localId=lid-only"), "has localId: {md}");
17821        assert!(!md.contains("params="), "no params: {md}");
17822        let rt = markdown_to_adf(&md).unwrap();
17823        assert_eq!(rt.content[0].local_id.as_deref(), Some("lid-only"));
17824        assert!(rt.content[0].parameters.is_none());
17825    }
17826
17827    #[test]
17828    fn nested_panel_inside_panel() {
17829        let md = ":::panel{type=info}\n:::panel{type=warning}\nInner warning\n:::\n:::";
17830        let adf = markdown_to_adf(md).unwrap();
17831
17832        // Outer panel should exist
17833        assert_eq!(adf.content.len(), 1);
17834        assert_eq!(adf.content[0].node_type, "panel");
17835
17836        // Outer panel should contain an inner panel (not have it truncated)
17837        let panel_content = adf.content[0].content.as_ref().unwrap();
17838        assert!(
17839            panel_content.iter().any(|n| n.node_type == "panel"),
17840            "Outer panel should contain an inner panel, got: {:?}",
17841            panel_content
17842                .iter()
17843                .map(|n| &n.node_type)
17844                .collect::<Vec<_>>()
17845        );
17846    }
17847
17848    #[test]
17849    fn content_after_directive_table_is_preserved() {
17850        // Issue #361: content after a ::::table block was silently dropped
17851        let md = "\
17852## Before table
17853
17854::::table{layout=default}
17855:::tr
17856:::th{}
17857Cell
17858:::
17859:::
17860::::
17861
17862## After table
17863
17864Paragraph after.";
17865        let adf = markdown_to_adf(md).unwrap();
17866        let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
17867        assert_eq!(
17868            types,
17869            vec!["heading", "table", "heading", "paragraph"],
17870            "Content after table was dropped: got {types:?}"
17871        );
17872    }
17873
17874    #[test]
17875    fn paragraph_after_directive_table_is_preserved() {
17876        // Issue #361: minimal reproducer — paragraph after table
17877        let md = "\
17878::::table{layout=default}
17879:::tr
17880:::th{}
17881Header
17882:::
17883:::
17884::::
17885
17886Just a paragraph.";
17887        let adf = markdown_to_adf(md).unwrap();
17888        let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
17889        assert_eq!(
17890            types,
17891            vec!["table", "paragraph"],
17892            "Paragraph after table was dropped: got {types:?}"
17893        );
17894    }
17895
17896    #[test]
17897    fn extension_after_directive_table_is_preserved() {
17898        // Issue #361: extension after table
17899        let md = "\
17900::::table{layout=default}
17901:::tr
17902:::th{}
17903Header
17904:::
17905:::
17906::::
17907
17908::extension{type=com.atlassian.confluence.macro.core key=toc}";
17909        let adf = markdown_to_adf(md).unwrap();
17910        let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
17911        assert_eq!(
17912            types,
17913            vec!["table", "extension"],
17914            "Extension after table was dropped: got {types:?}"
17915        );
17916    }
17917
17918    #[test]
17919    fn multiple_blocks_after_directive_table() {
17920        // Issue #361: multiple blocks after table, including another table
17921        let md = "\
17922## Heading 1
17923
17924::::table{layout=default}
17925:::tr
17926:::td{}
17927A
17928:::
17929:::td{}
17930B
17931:::
17932:::
17933::::
17934
17935## Heading 2
17936
17937Some text.
17938
17939---
17940
17941::::table{layout=default}
17942:::tr
17943:::th{}
17944C
17945:::
17946:::
17947::::
17948
17949## Heading 3";
17950        let adf = markdown_to_adf(md).unwrap();
17951        let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
17952        assert_eq!(
17953            types,
17954            vec![
17955                "heading",
17956                "table",
17957                "heading",
17958                "paragraph",
17959                "rule",
17960                "table",
17961                "heading"
17962            ],
17963            "Content after tables was dropped: got {types:?}"
17964        );
17965    }
17966
17967    // ── Table caption tests (issue #382) ────────────────────────────
17968
17969    #[test]
17970    fn adf_table_caption_to_markdown() {
17971        let doc = AdfDocument {
17972            version: 1,
17973            doc_type: "doc".to_string(),
17974            content: vec![AdfNode::table(vec![
17975                AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
17976                    AdfNode::text("cell"),
17977                ])])]),
17978                AdfNode::caption(vec![AdfNode::text("Table caption")]),
17979            ])],
17980        };
17981        let md = adf_to_markdown(&doc).unwrap();
17982        assert!(
17983            md.contains("::::table"),
17984            "table with caption must use directive form"
17985        );
17986        assert!(
17987            md.contains(":::caption"),
17988            "caption directive missing, got: {md}"
17989        );
17990        assert!(
17991            md.contains("Table caption"),
17992            "caption text missing, got: {md}"
17993        );
17994    }
17995
17996    #[test]
17997    fn directive_table_caption_parses() {
17998        let md = "::::table\n:::tr\n:::td\ncell\n:::\n:::\n:::caption\nTable caption\n:::\n::::\n";
17999        let doc = markdown_to_adf(md).unwrap();
18000        let table = &doc.content[0];
18001        assert_eq!(table.node_type, "table");
18002        let children = table.content.as_ref().unwrap();
18003        assert_eq!(children.len(), 2, "expected row + caption");
18004        assert_eq!(children[0].node_type, "tableRow");
18005        assert_eq!(children[1].node_type, "caption");
18006        let caption_content = children[1].content.as_ref().unwrap();
18007        assert_eq!(caption_content[0].text.as_deref(), Some("Table caption"));
18008    }
18009
18010    #[test]
18011    fn table_caption_round_trip_from_adf_json() {
18012        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
18013          {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
18014          {"type":"caption","content":[{"type":"text","text":"Table caption"}]}
18015        ]}]}"#;
18016        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18017        let md = adf_to_markdown(&doc).unwrap();
18018        assert!(md.contains("Table caption"), "caption text lost in ADF→JFM");
18019        let round_tripped = markdown_to_adf(&md).unwrap();
18020        let children = round_tripped.content[0].content.as_ref().unwrap();
18021        let caption = children.iter().find(|n| n.node_type == "caption");
18022        assert!(caption.is_some(), "caption lost on round-trip");
18023        let caption_text = caption.unwrap().content.as_ref().unwrap();
18024        assert_eq!(caption_text[0].text.as_deref(), Some("Table caption"));
18025    }
18026
18027    #[test]
18028    fn table_caption_with_inline_marks_round_trips() {
18029        let doc = AdfDocument {
18030            version: 1,
18031            doc_type: "doc".to_string(),
18032            content: vec![AdfNode::table(vec![
18033                AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
18034                    AdfNode::text("data"),
18035                ])])]),
18036                AdfNode::caption(vec![
18037                    AdfNode::text("Caption with "),
18038                    AdfNode::text_with_marks("bold", vec![AdfMark::strong()]),
18039                ]),
18040            ])],
18041        };
18042        let md = adf_to_markdown(&doc).unwrap();
18043        assert!(md.contains("**bold**"), "bold mark missing in caption");
18044        let round_tripped = markdown_to_adf(&md).unwrap();
18045        let caption = round_tripped.content[0]
18046            .content
18047            .as_ref()
18048            .unwrap()
18049            .iter()
18050            .find(|n| n.node_type == "caption")
18051            .expect("caption node missing after round-trip");
18052        let inlines = caption.content.as_ref().unwrap();
18053        let bold_node = inlines.iter().find(|n| {
18054            n.marks
18055                .as_ref()
18056                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
18057        });
18058        assert!(bold_node.is_some(), "bold mark lost in caption round-trip");
18059    }
18060
18061    // ── table caption localId tests (issue #524) ──────────────────────
18062
18063    #[test]
18064    fn table_caption_localid_roundtrip() {
18065        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
18066          {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
18067          {"type":"caption","attrs":{"localId":"abcdef123456"},"content":[{"type":"text","text":"Table with localId"}]}
18068        ]}]}"#;
18069        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18070        let md = adf_to_markdown(&doc).unwrap();
18071        assert!(
18072            md.contains("localId=abcdef123456"),
18073            "table caption localId should appear in markdown: {md}"
18074        );
18075        let rt = markdown_to_adf(&md).unwrap();
18076        let caption = rt.content[0]
18077            .content
18078            .as_ref()
18079            .unwrap()
18080            .iter()
18081            .find(|n| n.node_type == "caption")
18082            .expect("caption should survive round-trip");
18083        assert_eq!(
18084            caption.attrs.as_ref().unwrap()["localId"],
18085            "abcdef123456",
18086            "table caption localId should round-trip"
18087        );
18088    }
18089
18090    #[test]
18091    fn table_caption_without_localid_unchanged() {
18092        let md = "::::table\n:::tr\n:::td\ncell\n:::\n:::\n:::caption\nPlain caption\n:::\n::::\n";
18093        let doc = markdown_to_adf(md).unwrap();
18094        let caption = doc.content[0]
18095            .content
18096            .as_ref()
18097            .unwrap()
18098            .iter()
18099            .find(|n| n.node_type == "caption")
18100            .unwrap();
18101        assert!(
18102            caption.attrs.is_none(),
18103            "table caption without localId should not gain attrs"
18104        );
18105        let md2 = adf_to_markdown(&doc).unwrap();
18106        assert!(!md2.contains("localId"), "no localId should appear: {md2}");
18107    }
18108
18109    #[test]
18110    fn table_caption_localid_stripped_when_option_set() {
18111        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
18112          {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
18113          {"type":"caption","attrs":{"localId":"abcdef123456"},"content":[{"type":"text","text":"Stripped"}]}
18114        ]}]}"#;
18115        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18116        let opts = RenderOptions {
18117            strip_local_ids: true,
18118            ..Default::default()
18119        };
18120        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
18121        assert!(
18122            !md.contains("localId"),
18123            "table caption localId should be stripped: {md}"
18124        );
18125    }
18126
18127    #[test]
18128    #[test]
18129    fn tablecell_empty_attrs_preserved_on_roundtrip() {
18130        // Issue #385: tableCell with empty attrs:{} dropped on round-trip
18131        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"}]}]}]}]}]}"#;
18132        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18133        let md = adf_to_markdown(&doc).unwrap();
18134        let round_tripped = markdown_to_adf(&md).unwrap();
18135        let rows = round_tripped.content[0].content.as_ref().unwrap();
18136        let cell = &rows[0].content.as_ref().unwrap()[0];
18137        assert!(
18138            cell.attrs.is_some(),
18139            "tableCell attrs should be preserved, got None"
18140        );
18141        assert_eq!(
18142            cell.attrs.as_ref().unwrap(),
18143            &serde_json::json!({}),
18144            "tableCell attrs should be an empty object"
18145        );
18146    }
18147
18148    #[test]
18149    fn tablecell_empty_attrs_serialized_in_json() {
18150        // Issue #385: ensure the serialized JSON includes "attrs":{}
18151        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"}]}]}]}]}]}"#;
18152        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18153        let md = adf_to_markdown(&doc).unwrap();
18154        let round_tripped = markdown_to_adf(&md).unwrap();
18155        let json = serde_json::to_string(&round_tripped).unwrap();
18156        assert!(
18157            json.contains(r#""attrs":{}"#),
18158            "serialized JSON should contain \"attrs\":{{}}, got: {json}"
18159        );
18160    }
18161
18162    #[test]
18163    fn tablecell_empty_attrs_renders_braces_in_markdown() {
18164        // Issue #385: tableCell with empty attrs should render {} prefix in pipe tables
18165        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"}]}]}]}]}]}"#;
18166        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18167        let md = adf_to_markdown(&doc).unwrap();
18168        // Cell with attrs:{} should have {} prefix, cell without attrs should not
18169        assert!(
18170            md.contains("{} hello"),
18171            "cell with empty attrs should render '{{}} hello', got: {md}"
18172        );
18173        assert!(
18174            !md.contains("{} world"),
18175            "cell without attrs should not render '{{}}', got: {md}"
18176        );
18177    }
18178
18179    #[test]
18180    fn tablecell_no_attrs_unchanged_on_roundtrip() {
18181        // Ensure tableCell without attrs stays without attrs
18182        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"}]}]}]}]}]}"#;
18183        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18184        let md = adf_to_markdown(&doc).unwrap();
18185        let round_tripped = markdown_to_adf(&md).unwrap();
18186        let rows = round_tripped.content[0].content.as_ref().unwrap();
18187        let cell = &rows[0].content.as_ref().unwrap()[0];
18188        assert!(
18189            cell.attrs.is_none(),
18190            "tableCell without attrs should stay None, got: {:?}",
18191            cell.attrs
18192        );
18193    }
18194
18195    #[test]
18196    fn tablecell_nonempty_attrs_preserved_on_roundtrip() {
18197        // Ensure tableCell with non-empty attrs still works
18198        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"}]}]}]}]}]}"##;
18199        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18200        let md = adf_to_markdown(&doc).unwrap();
18201        let round_tripped = markdown_to_adf(&md).unwrap();
18202        let rows = round_tripped.content[0].content.as_ref().unwrap();
18203        let cell = &rows[1].content.as_ref().unwrap()[0];
18204        let attrs = cell.attrs.as_ref().unwrap();
18205        assert_eq!(attrs["background"], "#DEEBFF");
18206        assert_eq!(attrs["colspan"], 2);
18207    }
18208
18209    #[test]
18210    fn pipe_table_not_used_when_caption_present() {
18211        let doc = AdfDocument {
18212            version: 1,
18213            doc_type: "doc".to_string(),
18214            content: vec![AdfNode::table(vec![
18215                AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
18216                    AdfNode::text("H"),
18217                ])])]),
18218                AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
18219                    AdfNode::text("D"),
18220                ])])]),
18221                AdfNode::caption(vec![AdfNode::text("cap")]),
18222            ])],
18223        };
18224        let md = adf_to_markdown(&doc).unwrap();
18225        assert!(
18226            md.contains("::::table"),
18227            "pipe syntax should not be used when caption is present"
18228        );
18229    }
18230
18231    // ── Issue #402: ordered-list-like text in list item hardBreak ──
18232
18233    #[test]
18234    fn hardbreak_with_ordered_marker_in_bullet_item_roundtrips() {
18235        // Issue #402: text starting with "2. " after a hardBreak inside a
18236        // bullet list item must not be re-parsed as a new ordered list.
18237        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18238          {"type":"listItem","content":[{"type":"paragraph","content":[
18239            {"type":"text","text":"1. First item"},
18240            {"type":"hardBreak"},
18241            {"type":"text","text":"2. Honouring existing commitments"}
18242          ]}]}
18243        ]}]}"#;
18244        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18245        let md = adf_to_markdown(&doc).unwrap();
18246
18247        // The continuation line must be indented so it stays within the list item.
18248        assert!(
18249            md.contains("  2. Honouring"),
18250            "Continuation line should be indented, got:\n{md}"
18251        );
18252
18253        // Round-trip back to ADF
18254        let rt = markdown_to_adf(&md).unwrap();
18255        let list = &rt.content[0];
18256        assert_eq!(list.node_type, "bulletList");
18257        let items = list.content.as_ref().unwrap();
18258        assert_eq!(
18259            items.len(),
18260            1,
18261            "Should be one list item, got {}",
18262            items.len()
18263        );
18264
18265        let para = &items[0].content.as_ref().unwrap()[0];
18266        let inlines = para.content.as_ref().unwrap();
18267        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18268        assert_eq!(
18269            types,
18270            vec!["text", "hardBreak", "text"],
18271            "Expected text+hardBreak+text, got {types:?}"
18272        );
18273        assert_eq!(
18274            inlines[2].text.as_deref().unwrap(),
18275            "2. Honouring existing commitments"
18276        );
18277    }
18278
18279    #[test]
18280    fn hardbreak_with_ordered_marker_in_ordered_item_roundtrips() {
18281        // Same as above but inside an ordered list.
18282        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
18283          {"type":"listItem","content":[{"type":"paragraph","content":[
18284            {"type":"text","text":"Introduction  "},
18285            {"type":"hardBreak"},
18286            {"type":"text","text":"3. Third point"}
18287          ]}]}
18288        ]}]}"#;
18289        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18290        let md = adf_to_markdown(&doc).unwrap();
18291        let rt = markdown_to_adf(&md).unwrap();
18292
18293        let list = &rt.content[0];
18294        assert_eq!(list.node_type, "orderedList");
18295        let items = list.content.as_ref().unwrap();
18296        assert_eq!(items.len(), 1);
18297
18298        let para = &items[0].content.as_ref().unwrap()[0];
18299        let inlines = para.content.as_ref().unwrap();
18300        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18301        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18302        assert_eq!(inlines[2].text.as_deref().unwrap(), "3. Third point");
18303    }
18304
18305    #[test]
18306    fn hardbreak_with_bullet_marker_in_bullet_item_roundtrips() {
18307        // Text starting with "- " after a hardBreak must not become a nested bullet list.
18308        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18309          {"type":"listItem","content":[{"type":"paragraph","content":[
18310            {"type":"text","text":"Header  "},
18311            {"type":"hardBreak"},
18312            {"type":"text","text":"- not a sub-item"}
18313          ]}]}
18314        ]}]}"#;
18315        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18316        let md = adf_to_markdown(&doc).unwrap();
18317        let rt = markdown_to_adf(&md).unwrap();
18318
18319        let list = &rt.content[0];
18320        assert_eq!(list.node_type, "bulletList");
18321        let items = list.content.as_ref().unwrap();
18322        assert_eq!(
18323            items.len(),
18324            1,
18325            "Should be one list item, not {}",
18326            items.len()
18327        );
18328
18329        let para = &items[0].content.as_ref().unwrap()[0];
18330        let inlines = para.content.as_ref().unwrap();
18331        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18332        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18333        assert_eq!(inlines[2].text.as_deref().unwrap(), "- not a sub-item");
18334    }
18335
18336    #[test]
18337    fn hardbreak_continuation_followed_by_sub_list() {
18338        // A hardBreak continuation line followed by a real sub-list.
18339        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18340          {"type":"listItem","content":[
18341            {"type":"paragraph","content":[
18342              {"type":"text","text":"Main item  "},
18343              {"type":"hardBreak"},
18344              {"type":"text","text":"continued here"}
18345            ]},
18346            {"type":"bulletList","content":[
18347              {"type":"listItem","content":[{"type":"paragraph","content":[
18348                {"type":"text","text":"sub-item"}
18349              ]}]}
18350            ]}
18351          ]}
18352        ]}]}"#;
18353        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18354        let md = adf_to_markdown(&doc).unwrap();
18355        let rt = markdown_to_adf(&md).unwrap();
18356
18357        let list = &rt.content[0];
18358        let items = list.content.as_ref().unwrap();
18359        assert_eq!(items.len(), 1);
18360
18361        let item_content = items[0].content.as_ref().unwrap();
18362        assert_eq!(item_content.len(), 2, "Expected paragraph + nested list");
18363        assert_eq!(item_content[0].node_type, "paragraph");
18364        assert_eq!(item_content[1].node_type, "bulletList");
18365
18366        // Check the paragraph has hardBreak
18367        let inlines = item_content[0].content.as_ref().unwrap();
18368        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18369        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18370    }
18371
18372    #[test]
18373    fn multiple_hardbreaks_with_numbered_text_roundtrip() {
18374        // Multiple hardBreaks where each continuation resembles an ordered list.
18375        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18376          {"type":"listItem","content":[{"type":"paragraph","content":[
18377            {"type":"text","text":"Preamble  "},
18378            {"type":"hardBreak"},
18379            {"type":"text","text":"1. Alpha  "},
18380            {"type":"hardBreak"},
18381            {"type":"text","text":"2. Bravo"}
18382          ]}]}
18383        ]}]}"#;
18384        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18385        let md = adf_to_markdown(&doc).unwrap();
18386        let rt = markdown_to_adf(&md).unwrap();
18387
18388        let items = rt.content[0].content.as_ref().unwrap();
18389        assert_eq!(items.len(), 1);
18390
18391        let inlines = items[0].content.as_ref().unwrap()[0]
18392            .content
18393            .as_ref()
18394            .unwrap();
18395        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18396        assert_eq!(
18397            types,
18398            vec!["text", "hardBreak", "text", "hardBreak", "text"]
18399        );
18400    }
18401
18402    #[test]
18403    fn trailing_hardbreak_in_bullet_item_roundtrips() {
18404        // A hardBreak as the last inline node with no text after it.
18405        // Exercises the `break` path in the continuation loop and the
18406        // empty-line rendering branch.
18407        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18408          {"type":"listItem","content":[{"type":"paragraph","content":[
18409            {"type":"text","text":"ends with break"},
18410            {"type":"hardBreak"}
18411          ]}]}
18412        ]}]}"#;
18413        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18414        let md = adf_to_markdown(&doc).unwrap();
18415        let rt = markdown_to_adf(&md).unwrap();
18416
18417        let list = &rt.content[0];
18418        assert_eq!(list.node_type, "bulletList");
18419        let inlines = list.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0]
18420            .content
18421            .as_ref()
18422            .unwrap();
18423        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18424        assert_eq!(types, vec!["text", "hardBreak"]);
18425    }
18426
18427    #[test]
18428    fn trailing_hardbreak_in_ordered_item_roundtrips() {
18429        // Same as above but in an ordered list, covering the ordered-list
18430        // continuation `break` path.
18431        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
18432          {"type":"listItem","content":[{"type":"paragraph","content":[
18433            {"type":"text","text":"ends with break"},
18434            {"type":"hardBreak"}
18435          ]}]}
18436        ]}]}"#;
18437        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18438        let md = adf_to_markdown(&doc).unwrap();
18439        let rt = markdown_to_adf(&md).unwrap();
18440
18441        let list = &rt.content[0];
18442        assert_eq!(list.node_type, "orderedList");
18443        let inlines = list.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0]
18444            .content
18445            .as_ref()
18446            .unwrap();
18447        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18448        assert_eq!(types, vec!["text", "hardBreak"]);
18449    }
18450
18451    #[test]
18452    fn trailing_space_hardbreak_continuation_in_bullet_item() {
18453        // Exercises the `ends_with("  ")` path in `has_trailing_hard_break`
18454        // by parsing hand-written markdown that uses trailing-space style
18455        // hardBreaks instead of backslash style.
18456        let md = "- first line  \n  2. continued\n";
18457        let doc = markdown_to_adf(md).unwrap();
18458
18459        let list = &doc.content[0];
18460        assert_eq!(list.node_type, "bulletList");
18461        let items = list.content.as_ref().unwrap();
18462        assert_eq!(
18463            items.len(),
18464            1,
18465            "Should be one list item, got {}",
18466            items.len()
18467        );
18468
18469        let para = &items[0].content.as_ref().unwrap()[0];
18470        let inlines = para.content.as_ref().unwrap();
18471        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18472        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18473        assert_eq!(inlines[2].text.as_deref().unwrap(), "2. continued");
18474    }
18475
18476    #[test]
18477    fn trailing_space_hardbreak_continuation_in_ordered_item() {
18478        // Same as above but for ordered list, exercising the trailing-space
18479        // path in the ordered-list continuation loop.
18480        let md = "1. first line  \n  - continued\n";
18481        let doc = markdown_to_adf(md).unwrap();
18482
18483        let list = &doc.content[0];
18484        assert_eq!(list.node_type, "orderedList");
18485        let items = list.content.as_ref().unwrap();
18486        assert_eq!(
18487            items.len(),
18488            1,
18489            "Should be one list item, got {}",
18490            items.len()
18491        );
18492
18493        let para = &items[0].content.as_ref().unwrap()[0];
18494        let inlines = para.content.as_ref().unwrap();
18495        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18496        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18497        assert_eq!(inlines[2].text.as_deref().unwrap(), "- continued");
18498    }
18499
18500    #[test]
18501    fn multi_paragraph_list_item_with_ordered_marker_roundtrips() {
18502        // Issue #402 comment: a listItem with a second paragraph starting
18503        // with "2. " must not become a separate orderedList.
18504        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18505          {"type":"listItem","content":[
18506            {"type":"paragraph","content":[{"type":"text","text":"some preamble"}]},
18507            {"type":"paragraph","content":[{"type":"text","text":"2. Honouring existing commitments"}]}
18508          ]}
18509        ]}]}"#;
18510        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18511        let md = adf_to_markdown(&doc).unwrap();
18512        let rt = markdown_to_adf(&md).unwrap();
18513
18514        assert_eq!(rt.content.len(), 1, "Should be one top-level block");
18515        let list = &rt.content[0];
18516        assert_eq!(list.node_type, "bulletList");
18517        let items = list.content.as_ref().unwrap();
18518        assert_eq!(items.len(), 1);
18519        let item_content = items[0].content.as_ref().unwrap();
18520        assert_eq!(
18521            item_content.len(),
18522            2,
18523            "Expected 2 paragraphs inside the list item, got {}",
18524            item_content.len()
18525        );
18526        assert_eq!(item_content[0].node_type, "paragraph");
18527        assert_eq!(item_content[1].node_type, "paragraph");
18528        let text = item_content[1].content.as_ref().unwrap()[0]
18529            .text
18530            .as_deref()
18531            .unwrap();
18532        assert_eq!(text, "2. Honouring existing commitments");
18533    }
18534
18535    #[test]
18536    fn multi_paragraph_list_item_with_bullet_marker_roundtrips() {
18537        // Paragraph starting with "- " inside a list item.
18538        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18539          {"type":"listItem","content":[
18540            {"type":"paragraph","content":[{"type":"text","text":"preamble"}]},
18541            {"type":"paragraph","content":[{"type":"text","text":"- not a sub-item"}]}
18542          ]}
18543        ]}]}"#;
18544        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18545        let md = adf_to_markdown(&doc).unwrap();
18546        let rt = markdown_to_adf(&md).unwrap();
18547
18548        let items = rt.content[0].content.as_ref().unwrap();
18549        assert_eq!(items.len(), 1);
18550        let item_content = items[0].content.as_ref().unwrap();
18551        assert_eq!(item_content.len(), 2);
18552        assert_eq!(item_content[1].node_type, "paragraph");
18553        let text = item_content[1].content.as_ref().unwrap()[0]
18554            .text
18555            .as_deref()
18556            .unwrap();
18557        assert_eq!(text, "- not a sub-item");
18558    }
18559
18560    #[test]
18561    fn backslash_escape_in_inline_text() {
18562        // Verify that `\. ` is unescaped to `. ` in inline parsing.
18563        let nodes = parse_inline(r"2\. text");
18564        assert_eq!(nodes.len(), 1, "Should be one text node");
18565        assert_eq!(nodes[0].text.as_deref().unwrap(), "2. text");
18566    }
18567
18568    #[test]
18569    fn escape_list_marker_ordered() {
18570        assert_eq!(escape_list_marker("2. text"), r"2\. text");
18571        assert_eq!(escape_list_marker("10. tenth"), r"10\. tenth");
18572    }
18573
18574    #[test]
18575    fn escape_list_marker_bullet() {
18576        assert_eq!(escape_list_marker("- text"), r"\- text");
18577        assert_eq!(escape_list_marker("* text"), r"\* text");
18578        assert_eq!(escape_list_marker("+ text"), r"\+ text");
18579    }
18580
18581    #[test]
18582    fn escape_list_marker_plain() {
18583        assert_eq!(escape_list_marker("plain text"), "plain text");
18584        assert_eq!(escape_list_marker("no. marker"), "no. marker");
18585    }
18586
18587    #[test]
18588    fn escape_emoji_shortcodes_basic() {
18589        assert_eq!(escape_emoji_shortcodes(":fire:"), r"\:fire:");
18590        assert_eq!(
18591            escape_emoji_shortcodes("hello :wave: world"),
18592            r"hello \:wave: world"
18593        );
18594    }
18595
18596    #[test]
18597    fn escape_emoji_shortcodes_double_colon() {
18598        // Only the colon that starts `:Active:` needs escaping
18599        assert_eq!(
18600            escape_emoji_shortcodes("Status::Active::Running"),
18601            r"Status:\:Active::Running"
18602        );
18603    }
18604
18605    #[test]
18606    fn escape_emoji_shortcodes_no_match() {
18607        // Lone colons, numeric-only between colons like 10:30
18608        assert_eq!(escape_emoji_shortcodes("Time is 10:30"), "Time is 10:30");
18609        assert_eq!(escape_emoji_shortcodes("no colons here"), "no colons here");
18610        assert_eq!(escape_emoji_shortcodes("trailing:"), "trailing:");
18611        assert_eq!(escape_emoji_shortcodes(":"), ":");
18612    }
18613
18614    #[test]
18615    fn escape_emoji_shortcodes_mixed() {
18616        assert_eq!(
18617            escape_emoji_shortcodes("Alert :fire: on pod:pod42"),
18618            r"Alert \:fire: on pod:pod42"
18619        );
18620    }
18621
18622    #[test]
18623    fn escape_emoji_shortcodes_unicode() {
18624        // Issue #552: Unicode alphanumeric chars must be escaped to match
18625        // `try_parse_emoji_shortcode`, which uses `is_alphanumeric` (not the
18626        // ASCII-only variant).  Without this, `:Café:` rendered un-escaped
18627        // would be re-parsed as an emoji on round-trip.
18628        assert_eq!(escape_emoji_shortcodes(":Café:"), r"\:Café:");
18629        assert_eq!(escape_emoji_shortcodes(":über:"), r"\:über:");
18630        assert_eq!(escape_emoji_shortcodes(":配置:"), r"\:配置:");
18631        assert_eq!(
18632            escape_emoji_shortcodes("ZBC::配置::Production"),
18633            r"ZBC:\:配置::Production"
18634        );
18635    }
18636
18637    #[test]
18638    fn escape_emoji_shortcodes_mixed_script_name() {
18639        // Issue #552: A name that mixes ASCII and Unicode alphanumerics is
18640        // still a single valid shortcode under `is_alphanumeric`.
18641        assert_eq!(escape_emoji_shortcodes(":abc配置:"), r"\:abc配置:");
18642        assert_eq!(escape_emoji_shortcodes(":配置abc:"), r"\:配置abc:");
18643    }
18644
18645    #[test]
18646    fn escape_emoji_shortcodes_unicode_followed_by_non_colon() {
18647        // `:Café world:` — `Café` is alphanumeric but the terminator is a
18648        // space, not `:`, so the `after + name_end < text.len()` path is
18649        // exercised but the final `== b':'` check bails out and nothing
18650        // gets escaped.  Guards the negative branch of the predicate.
18651        assert_eq!(escape_emoji_shortcodes(":Café world:"), ":Café world:");
18652    }
18653
18654    #[test]
18655    fn escape_emoji_shortcodes_name_runs_to_end() {
18656        // Colon followed by alphanumerics to end of string: `.find(...)` returns
18657        // `None`, so `name_end` falls back to the full remaining length via
18658        // `map_or`.  The `after + name_end < text.len()` check then fails and
18659        // nothing is escaped.  Exercises the `map_or` default branch for both
18660        // ASCII and Unicode names.
18661        assert_eq!(escape_emoji_shortcodes(":abc"), ":abc");
18662        assert_eq!(escape_emoji_shortcodes(":配置"), ":配置");
18663    }
18664
18665    #[test]
18666    fn unicode_shortcode_pattern_text_round_trips_as_text() {
18667        // Issue #552: A text node containing `:Café:` must round-trip as text,
18668        // not be split into text + emoji nodes.
18669        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18670          {"type":"text","text":"Visit :Café: today"}
18671        ]}]}"#;
18672        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18673
18674        let md = adf_to_markdown(&doc).unwrap();
18675        let round_tripped = markdown_to_adf(&md).unwrap();
18676        let content = round_tripped.content[0].content.as_ref().unwrap();
18677
18678        assert_eq!(
18679            content.len(),
18680            1,
18681            "should be a single text node, got: {content:?}"
18682        );
18683        assert_eq!(content[0].node_type, "text");
18684        assert_eq!(content[0].text.as_deref().unwrap(), "Visit :Café: today");
18685    }
18686
18687    #[test]
18688    fn unicode_double_colon_pattern_text_round_trips() {
18689        // Issue #552: `ZBC::配置::Production` should round-trip without splitting.
18690        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18691          {"type":"text","text":"Use ZBC::配置::Production for prod"}
18692        ]}]}"#;
18693        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18694
18695        let md = adf_to_markdown(&doc).unwrap();
18696        let round_tripped = markdown_to_adf(&md).unwrap();
18697        let content = round_tripped.content[0].content.as_ref().unwrap();
18698
18699        assert_eq!(
18700            content.len(),
18701            1,
18702            "should be a single text node, got: {content:?}"
18703        );
18704        assert_eq!(
18705            content[0].text.as_deref().unwrap(),
18706            "Use ZBC::配置::Production for prod"
18707        );
18708    }
18709
18710    #[test]
18711    fn merge_adjacent_text_nodes() {
18712        let mut nodes = vec![AdfNode::text("a"), AdfNode::text("b"), AdfNode::text("c")];
18713        merge_adjacent_text(&mut nodes);
18714        assert_eq!(nodes.len(), 1);
18715        assert_eq!(nodes[0].text.as_deref().unwrap(), "abc");
18716    }
18717
18718    // ── Issue #455: text after hardBreak in paragraph re-parsed as list ──
18719
18720    #[test]
18721    fn issue_455_paragraph_hardbreak_ordered_marker_roundtrips() {
18722        // Issue #455: "1. text" after a hardBreak in a paragraph must not
18723        // become an ordered list.
18724        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18725          {"type":"text","text":"Introduction: "},
18726          {"type":"hardBreak"},
18727          {"type":"text","text":"1. This text follows a hardBreak"}
18728        ]}]}"#;
18729        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18730        let md = adf_to_markdown(&doc).unwrap();
18731        let rt = markdown_to_adf(&md).unwrap();
18732
18733        assert_eq!(rt.content.len(), 1, "Should remain one block");
18734        assert_eq!(rt.content[0].node_type, "paragraph");
18735        let inlines = rt.content[0].content.as_ref().unwrap();
18736        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18737        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18738        assert_eq!(
18739            inlines[2].text.as_deref(),
18740            Some("1. This text follows a hardBreak")
18741        );
18742    }
18743
18744    #[test]
18745    fn issue_455_paragraph_hardbreak_bullet_marker_roundtrips() {
18746        // Issue #455 variant: "- text" after a hardBreak in a paragraph.
18747        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18748          {"type":"text","text":"Intro"},
18749          {"type":"hardBreak"},
18750          {"type":"text","text":"- not a list item"}
18751        ]}]}"#;
18752        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18753        let md = adf_to_markdown(&doc).unwrap();
18754        let rt = markdown_to_adf(&md).unwrap();
18755
18756        assert_eq!(rt.content.len(), 1);
18757        assert_eq!(rt.content[0].node_type, "paragraph");
18758        let inlines = rt.content[0].content.as_ref().unwrap();
18759        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18760        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18761        assert_eq!(inlines[2].text.as_deref(), Some("- not a list item"));
18762    }
18763
18764    #[test]
18765    fn issue_455_paragraph_hardbreak_heading_marker_roundtrips() {
18766        // Issue #455 variant: "# text" after a hardBreak in a paragraph.
18767        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18768          {"type":"text","text":"Intro"},
18769          {"type":"hardBreak"},
18770          {"type":"text","text":"# not a heading"}
18771        ]}]}"##;
18772        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18773        let md = adf_to_markdown(&doc).unwrap();
18774        let rt = markdown_to_adf(&md).unwrap();
18775
18776        assert_eq!(rt.content.len(), 1);
18777        assert_eq!(rt.content[0].node_type, "paragraph");
18778        let inlines = rt.content[0].content.as_ref().unwrap();
18779        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18780        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18781        assert_eq!(inlines[2].text.as_deref(), Some("# not a heading"));
18782    }
18783
18784    #[test]
18785    fn issue_455_paragraph_hardbreak_blockquote_marker_roundtrips() {
18786        // Issue #455 variant: "> text" after a hardBreak in a paragraph.
18787        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18788          {"type":"text","text":"Intro"},
18789          {"type":"hardBreak"},
18790          {"type":"text","text":"> not a blockquote"}
18791        ]}]}"#;
18792        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18793        let md = adf_to_markdown(&doc).unwrap();
18794        let rt = markdown_to_adf(&md).unwrap();
18795
18796        assert_eq!(rt.content.len(), 1);
18797        assert_eq!(rt.content[0].node_type, "paragraph");
18798        let inlines = rt.content[0].content.as_ref().unwrap();
18799        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18800        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18801        assert_eq!(inlines[2].text.as_deref(), Some("> not a blockquote"));
18802    }
18803
18804    #[test]
18805    fn issue_455_paragraph_multiple_hardbreaks_with_ordered_markers() {
18806        // Multiple hardBreaks in a paragraph, each followed by "N. text".
18807        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18808          {"type":"text","text":"Preamble"},
18809          {"type":"hardBreak"},
18810          {"type":"text","text":"1. First"},
18811          {"type":"hardBreak"},
18812          {"type":"text","text":"2. Second"},
18813          {"type":"hardBreak"},
18814          {"type":"text","text":"3. Third"}
18815        ]}]}"#;
18816        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18817        let md = adf_to_markdown(&doc).unwrap();
18818        let rt = markdown_to_adf(&md).unwrap();
18819
18820        assert_eq!(rt.content.len(), 1);
18821        assert_eq!(rt.content[0].node_type, "paragraph");
18822        let inlines = rt.content[0].content.as_ref().unwrap();
18823        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18824        assert_eq!(
18825            types,
18826            vec![
18827                "text",
18828                "hardBreak",
18829                "text",
18830                "hardBreak",
18831                "text",
18832                "hardBreak",
18833                "text"
18834            ]
18835        );
18836        assert_eq!(inlines[2].text.as_deref(), Some("1. First"));
18837        assert_eq!(inlines[4].text.as_deref(), Some("2. Second"));
18838        assert_eq!(inlines[6].text.as_deref(), Some("3. Third"));
18839    }
18840
18841    #[test]
18842    fn issue_455_paragraph_hardbreak_jfm_indentation() {
18843        // Verify that ADF→JFM output indents continuation lines.
18844        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18845          {"type":"text","text":"Intro"},
18846          {"type":"hardBreak"},
18847          {"type":"text","text":"1. continued"}
18848        ]}]}"#;
18849        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18850        let md = adf_to_markdown(&doc).unwrap();
18851        assert!(
18852            md.contains("Intro\\\n  1. continued"),
18853            "Continuation should be 2-space-indented, got: {md:?}"
18854        );
18855    }
18856
18857    #[test]
18858    fn issue_455_paragraph_hardbreak_from_jfm() {
18859        // Verify that JFM with 2-space-indented continuation is parsed
18860        // back as a single paragraph with hardBreak.
18861        let md = "Intro\\\n  1. This is continuation text\n";
18862        let doc = markdown_to_adf(md).unwrap();
18863
18864        assert_eq!(doc.content.len(), 1);
18865        assert_eq!(doc.content[0].node_type, "paragraph");
18866        let inlines = doc.content[0].content.as_ref().unwrap();
18867        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18868        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18869        assert_eq!(
18870            inlines[2].text.as_deref(),
18871            Some("1. This is continuation text")
18872        );
18873    }
18874
18875    #[test]
18876    fn issue_455_paragraph_starts_with_ordered_marker_and_hardbreak() {
18877        // Coverage: first line IS a list marker AND paragraph has hardBreaks.
18878        // Exercises the escape_list_marker path on the first line of a
18879        // multi-line paragraph buf in the rendering code.
18880        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18881          {"type":"text","text":"1. Starting with a number"},
18882          {"type":"hardBreak"},
18883          {"type":"text","text":"continuation after break"}
18884        ]}]}"#;
18885        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18886        let md = adf_to_markdown(&doc).unwrap();
18887        // First line should be escaped so it's not parsed as ordered list
18888        assert!(
18889            md.contains(r"1\. Starting with a number"),
18890            "First line should have escaped list marker, got: {md:?}"
18891        );
18892        let rt = markdown_to_adf(&md).unwrap();
18893
18894        assert_eq!(rt.content.len(), 1);
18895        assert_eq!(rt.content[0].node_type, "paragraph");
18896        let inlines = rt.content[0].content.as_ref().unwrap();
18897        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18898        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18899        assert_eq!(
18900            inlines[0].text.as_deref(),
18901            Some("1. Starting with a number")
18902        );
18903        assert_eq!(inlines[2].text.as_deref(), Some("continuation after break"));
18904    }
18905
18906    #[test]
18907    fn ordered_marker_paragraph_in_table_cell_roundtrips() {
18908        // Issue #402: paragraph with "2. " text inside a tableCell must
18909        // not be re-parsed as an ordered list.
18910        let adf_json = r#"{"version":1,"type":"doc","content":[{
18911          "type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},
18912          "content":[{"type":"tableRow","content":[{
18913            "type":"tableCell","attrs":{"colspan":1,"rowspan":1},
18914            "content":[{"type":"paragraph","content":[
18915              {"type":"text","text":"2. Honouring existing commitments"}
18916            ]}]
18917          }]}]
18918        }]}"#;
18919        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18920        let md = adf_to_markdown(&doc).unwrap();
18921        let rt = markdown_to_adf(&md).unwrap();
18922
18923        let table = &rt.content[0];
18924        let cell = &table.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0];
18925        let para = &cell.content.as_ref().unwrap()[0];
18926        assert_eq!(para.node_type, "paragraph");
18927        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
18928        assert_eq!(text, "2. Honouring existing commitments");
18929    }
18930
18931    #[test]
18932    fn bullet_marker_paragraph_standalone_roundtrips() {
18933        // A top-level paragraph starting with "- " must round-trip as
18934        // a paragraph, not a bullet list.
18935        let adf_json = r#"{"version":1,"type":"doc","content":[
18936          {"type":"paragraph","content":[
18937            {"type":"text","text":"- not a list item"}
18938          ]}
18939        ]}"#;
18940        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18941        let md = adf_to_markdown(&doc).unwrap();
18942        assert!(
18943            md.contains(r"\- not a list item"),
18944            "Should escape the leading dash, got:\n{md}"
18945        );
18946        let rt = markdown_to_adf(&md).unwrap();
18947        assert_eq!(rt.content[0].node_type, "paragraph");
18948        let text = rt.content[0].content.as_ref().unwrap()[0]
18949            .text
18950            .as_deref()
18951            .unwrap();
18952        assert_eq!(text, "- not a list item");
18953    }
18954
18955    #[test]
18956    fn merge_adjacent_text_skips_non_text_nodes() {
18957        // Exercises the `else { i += 1 }` branch when adjacent nodes
18958        // are not both plain text.
18959        let mut nodes = vec![
18960            AdfNode::text("a"),
18961            AdfNode::hard_break(),
18962            AdfNode::text("b"),
18963        ];
18964        merge_adjacent_text(&mut nodes);
18965        assert_eq!(nodes.len(), 3);
18966    }
18967
18968    #[test]
18969    fn star_bullet_paragraph_roundtrips() {
18970        // Paragraph starting with "* " must round-trip without becoming
18971        // a bullet list.
18972        let adf_json = r#"{"version":1,"type":"doc","content":[
18973          {"type":"paragraph","content":[
18974            {"type":"text","text":"* starred"}
18975          ]}
18976        ]}"#;
18977        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18978        let md = adf_to_markdown(&doc).unwrap();
18979        let rt = markdown_to_adf(&md).unwrap();
18980        assert_eq!(rt.content[0].node_type, "paragraph");
18981        assert_eq!(
18982            rt.content[0].content.as_ref().unwrap()[0]
18983                .text
18984                .as_deref()
18985                .unwrap(),
18986            "* starred"
18987        );
18988    }
18989
18990    // ---- Issue #388 tests ----
18991
18992    #[test]
18993    fn issue_388_ordered_list_with_strong_hardbreak_roundtrips() {
18994        // Issue #388: orderedList with 2 listItems, each containing
18995        // strong-marked text + hardBreak + plain text.
18996        let adf_json = r#"{"version":1,"type":"doc","content":[
18997          {"type":"orderedList","attrs":{"order":1},"content":[
18998            {"type":"listItem","content":[
18999              {"type":"paragraph","content":[
19000                {"type":"text","text":"Bold heading","marks":[{"type":"strong"}]},
19001                {"type":"hardBreak"},
19002                {"type":"text","text":"Content after break"}
19003              ]}
19004            ]},
19005            {"type":"listItem","content":[
19006              {"type":"paragraph","content":[
19007                {"type":"text","text":"Second item","marks":[{"type":"strong"}]},
19008                {"type":"hardBreak"},
19009                {"type":"text","text":"More content"}
19010              ]}
19011            ]}
19012          ]}
19013        ]}"#;
19014        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19015        let md = adf_to_markdown(&doc).unwrap();
19016        let rt = markdown_to_adf(&md).unwrap();
19017
19018        // Must remain a single orderedList
19019        assert_eq!(
19020            rt.content.len(),
19021            1,
19022            "Should be 1 block (orderedList), got {}",
19023            rt.content.len()
19024        );
19025        assert_eq!(rt.content[0].node_type, "orderedList");
19026        let items = rt.content[0].content.as_ref().unwrap();
19027        assert_eq!(
19028            items.len(),
19029            2,
19030            "Should have 2 listItems, got {}",
19031            items.len()
19032        );
19033
19034        // First item: text(strong) + hardBreak + text
19035        let p1 = items[0].content.as_ref().unwrap()[0]
19036            .content
19037            .as_ref()
19038            .unwrap();
19039        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
19040        assert_eq!(types1, vec!["text", "hardBreak", "text"]);
19041        assert_eq!(p1[0].text.as_deref(), Some("Bold heading"));
19042        assert_eq!(p1[2].text.as_deref(), Some("Content after break"));
19043
19044        // Second item: text(strong) + hardBreak + text
19045        let p2 = items[1].content.as_ref().unwrap()[0]
19046            .content
19047            .as_ref()
19048            .unwrap();
19049        let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
19050        assert_eq!(types2, vec!["text", "hardBreak", "text"]);
19051        assert_eq!(p2[0].text.as_deref(), Some("Second item"));
19052        assert_eq!(p2[2].text.as_deref(), Some("More content"));
19053    }
19054
19055    #[test]
19056    fn issue_388_bullet_list_with_strong_hardbreak_roundtrips() {
19057        // Bullet list variant of issue #388.
19058        let adf_json = r#"{"version":1,"type":"doc","content":[
19059          {"type":"bulletList","content":[
19060            {"type":"listItem","content":[
19061              {"type":"paragraph","content":[
19062                {"type":"text","text":"First","marks":[{"type":"strong"}]},
19063                {"type":"hardBreak"},
19064                {"type":"text","text":"details"}
19065              ]}
19066            ]},
19067            {"type":"listItem","content":[
19068              {"type":"paragraph","content":[
19069                {"type":"text","text":"Second","marks":[{"type":"em"}]},
19070                {"type":"hardBreak"},
19071                {"type":"text","text":"more details"}
19072              ]}
19073            ]}
19074          ]}
19075        ]}"#;
19076        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19077        let md = adf_to_markdown(&doc).unwrap();
19078        let rt = markdown_to_adf(&md).unwrap();
19079
19080        assert_eq!(rt.content.len(), 1);
19081        assert_eq!(rt.content[0].node_type, "bulletList");
19082        let items = rt.content[0].content.as_ref().unwrap();
19083        assert_eq!(items.len(), 2);
19084
19085        let p1 = items[0].content.as_ref().unwrap()[0]
19086            .content
19087            .as_ref()
19088            .unwrap();
19089        assert_eq!(p1[0].text.as_deref(), Some("First"));
19090        assert_eq!(p1[2].text.as_deref(), Some("details"));
19091
19092        let p2 = items[1].content.as_ref().unwrap()[0]
19093            .content
19094            .as_ref()
19095            .unwrap();
19096        assert_eq!(p2[0].text.as_deref(), Some("Second"));
19097        assert_eq!(p2[2].text.as_deref(), Some("more details"));
19098    }
19099
19100    #[test]
19101    fn issue_388_ordered_list_hardbreak_jfm_indentation() {
19102        // Verify the JFM output has properly indented continuation lines.
19103        let adf_json = r#"{"version":1,"type":"doc","content":[
19104          {"type":"orderedList","attrs":{"order":1},"content":[
19105            {"type":"listItem","content":[
19106              {"type":"paragraph","content":[
19107                {"type":"text","text":"heading","marks":[{"type":"strong"}]},
19108                {"type":"hardBreak"},
19109                {"type":"text","text":"body"}
19110              ]}
19111            ]}
19112          ]}
19113        ]}"#;
19114        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19115        let md = adf_to_markdown(&doc).unwrap();
19116        assert!(
19117            md.contains("1. **heading**\\\n  body"),
19118            "Continuation should be indented, got:\n{md}"
19119        );
19120    }
19121
19122    #[test]
19123    fn issue_388_ordered_list_hardbreak_from_jfm() {
19124        // Direct JFM → ADF: ordered list with hardBreak continuation.
19125        let md = "1. **bold**\\\n  continued\n2. **also bold**\\\n  also continued\n";
19126        let doc = markdown_to_adf(md).unwrap();
19127
19128        assert_eq!(doc.content.len(), 1);
19129        assert_eq!(doc.content[0].node_type, "orderedList");
19130        let items = doc.content[0].content.as_ref().unwrap();
19131        assert_eq!(items.len(), 2);
19132
19133        let p1 = items[0].content.as_ref().unwrap()[0]
19134            .content
19135            .as_ref()
19136            .unwrap();
19137        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
19138        assert_eq!(types1, vec!["text", "hardBreak", "text"]);
19139        assert_eq!(p1[0].text.as_deref(), Some("bold"));
19140        assert_eq!(p1[2].text.as_deref(), Some("continued"));
19141
19142        let p2 = items[1].content.as_ref().unwrap()[0]
19143            .content
19144            .as_ref()
19145            .unwrap();
19146        let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
19147        assert_eq!(types2, vec!["text", "hardBreak", "text"]);
19148    }
19149
19150    #[test]
19151    fn issue_388_bullet_list_hardbreak_from_jfm() {
19152        // Direct JFM → ADF: bullet list with hardBreak continuation.
19153        let md = "- first\\\n  second\n- third\\\n  fourth\n";
19154        let doc = markdown_to_adf(md).unwrap();
19155
19156        assert_eq!(doc.content.len(), 1);
19157        assert_eq!(doc.content[0].node_type, "bulletList");
19158        let items = doc.content[0].content.as_ref().unwrap();
19159        assert_eq!(items.len(), 2);
19160
19161        for (i, expected) in [("first", "second"), ("third", "fourth")]
19162            .iter()
19163            .enumerate()
19164        {
19165            let p = items[i].content.as_ref().unwrap()[0]
19166                .content
19167                .as_ref()
19168                .unwrap();
19169            let types: Vec<&str> = p.iter().map(|n| n.node_type.as_str()).collect();
19170            assert_eq!(types, vec!["text", "hardBreak", "text"]);
19171            assert_eq!(p[0].text.as_deref(), Some(expected.0));
19172            assert_eq!(p[2].text.as_deref(), Some(expected.1));
19173        }
19174    }
19175
19176    #[test]
19177    fn issue_433_heading_hardbreak_roundtrips() {
19178        // Issue #433: hardBreak inside heading splits into heading + paragraph.
19179        let adf_json = r#"{"version":1,"type":"doc","content":[{
19180          "type":"heading",
19181          "attrs":{"level":1},
19182          "content":[
19183            {"type":"text","text":"Line one"},
19184            {"type":"hardBreak"},
19185            {"type":"text","text":"Line two"}
19186          ]
19187        }]}"#;
19188        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19189        let md = adf_to_markdown(&doc).unwrap();
19190        let rt = markdown_to_adf(&md).unwrap();
19191
19192        assert_eq!(
19193            rt.content.len(),
19194            1,
19195            "Should remain a single heading, got {} blocks",
19196            rt.content.len()
19197        );
19198        assert_eq!(rt.content[0].node_type, "heading");
19199        let inlines = rt.content[0].content.as_ref().unwrap();
19200        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19201        assert_eq!(
19202            types,
19203            vec!["text", "hardBreak", "text"],
19204            "hardBreak should be preserved, got: {types:?}"
19205        );
19206        assert_eq!(inlines[0].text.as_deref(), Some("Line one"));
19207        assert_eq!(inlines[2].text.as_deref(), Some("Line two"));
19208    }
19209
19210    #[test]
19211    fn issue_433_heading_hardbreak_jfm_indentation() {
19212        // Verify the JFM output has properly indented continuation lines.
19213        let adf_json = r#"{"version":1,"type":"doc","content":[{
19214          "type":"heading",
19215          "attrs":{"level":2},
19216          "content":[
19217            {"type":"text","text":"Title"},
19218            {"type":"hardBreak"},
19219            {"type":"text","text":"Subtitle"}
19220          ]
19221        }]}"#;
19222        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19223        let md = adf_to_markdown(&doc).unwrap();
19224        assert!(
19225            md.contains("## Title\\\n  Subtitle"),
19226            "Continuation should be indented, got:\n{md}"
19227        );
19228    }
19229
19230    #[test]
19231    fn issue_433_heading_hardbreak_from_jfm() {
19232        // Direct JFM → ADF: heading with hardBreak continuation.
19233        let md = "# First\\\n  Second\n";
19234        let doc = markdown_to_adf(md).unwrap();
19235
19236        assert_eq!(doc.content.len(), 1);
19237        assert_eq!(doc.content[0].node_type, "heading");
19238        let inlines = doc.content[0].content.as_ref().unwrap();
19239        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19240        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19241        assert_eq!(inlines[0].text.as_deref(), Some("First"));
19242        assert_eq!(inlines[2].text.as_deref(), Some("Second"));
19243    }
19244
19245    #[test]
19246    fn issue_433_heading_consecutive_hardbreaks_roundtrip() {
19247        // Consecutive hardBreaks in a heading.
19248        let adf_json = r#"{"version":1,"type":"doc","content":[{
19249          "type":"heading",
19250          "attrs":{"level":3},
19251          "content":[
19252            {"type":"text","text":"A"},
19253            {"type":"hardBreak"},
19254            {"type":"hardBreak"},
19255            {"type":"text","text":"B"}
19256          ]
19257        }]}"#;
19258        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19259        let md = adf_to_markdown(&doc).unwrap();
19260        let rt = markdown_to_adf(&md).unwrap();
19261
19262        assert_eq!(rt.content.len(), 1, "Should remain a single heading");
19263        assert_eq!(rt.content[0].node_type, "heading");
19264        let inlines = rt.content[0].content.as_ref().unwrap();
19265        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19266        assert_eq!(types, vec!["text", "hardBreak", "hardBreak", "text"]);
19267    }
19268
19269    #[test]
19270    fn issue_433_heading_with_strong_and_hardbreak_roundtrips() {
19271        // Heading with strong-marked text + hardBreak + plain text.
19272        let adf_json = r#"{"version":1,"type":"doc","content":[{
19273          "type":"heading",
19274          "attrs":{"level":1},
19275          "content":[
19276            {"type":"text","text":"Bold title","marks":[{"type":"strong"}]},
19277            {"type":"hardBreak"},
19278            {"type":"text","text":"plain continuation"}
19279          ]
19280        }]}"#;
19281        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19282        let md = adf_to_markdown(&doc).unwrap();
19283        let rt = markdown_to_adf(&md).unwrap();
19284
19285        assert_eq!(rt.content.len(), 1);
19286        assert_eq!(rt.content[0].node_type, "heading");
19287        let inlines = rt.content[0].content.as_ref().unwrap();
19288        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19289        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19290        assert_eq!(inlines[0].text.as_deref(), Some("Bold title"));
19291        assert_eq!(inlines[2].text.as_deref(), Some("plain continuation"));
19292    }
19293
19294    #[test]
19295    fn issue_433_heading_with_link_and_hardbreak_roundtrips() {
19296        // Real-world pattern: heading with link + hardBreak + text.
19297        let adf_json = r#"{"version":1,"type":"doc","content":[{
19298          "type":"heading",
19299          "attrs":{"level":1},
19300          "content":[
19301            {"type":"text","text":"Click here","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
19302            {"type":"hardBreak"},
19303            {"type":"text","text":"Subtitle text"}
19304          ]
19305        }]}"#;
19306        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19307        let md = adf_to_markdown(&doc).unwrap();
19308        let rt = markdown_to_adf(&md).unwrap();
19309
19310        assert_eq!(rt.content.len(), 1);
19311        assert_eq!(rt.content[0].node_type, "heading");
19312        let inlines = rt.content[0].content.as_ref().unwrap();
19313        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19314        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19315        assert_eq!(inlines[2].text.as_deref(), Some("Subtitle text"));
19316    }
19317
19318    #[test]
19319    fn has_trailing_hard_break_backslash() {
19320        assert!(has_trailing_hard_break("text\\"));
19321        assert!(has_trailing_hard_break("**bold**\\"));
19322    }
19323
19324    #[test]
19325    fn has_trailing_hard_break_trailing_spaces() {
19326        assert!(has_trailing_hard_break("text  "));
19327        assert!(has_trailing_hard_break("word   "));
19328    }
19329
19330    #[test]
19331    fn has_trailing_hard_break_false() {
19332        assert!(!has_trailing_hard_break("plain text"));
19333        assert!(!has_trailing_hard_break("text "));
19334        assert!(!has_trailing_hard_break(""));
19335    }
19336
19337    #[test]
19338    fn collect_hardbreak_continuations_collects_indented() {
19339        // A line ending with `\` followed by 2-space-indented continuation.
19340        // Only one line is collected because the result no longer ends with `\`.
19341        let input = "first\\\n  second\n  third\n";
19342        let mut parser = MarkdownParser::new(input);
19343        parser.advance(); // skip first line
19344        let mut text = "first\\".to_string();
19345        parser.collect_hardbreak_continuations(&mut text);
19346        assert_eq!(text, "first\\\nsecond");
19347    }
19348
19349    #[test]
19350    fn collect_hardbreak_continuations_stops_at_non_indented() {
19351        let input = "first\\\nnot indented\n";
19352        let mut parser = MarkdownParser::new(input);
19353        parser.advance();
19354        let mut text = "first\\".to_string();
19355        parser.collect_hardbreak_continuations(&mut text);
19356        // Should NOT collect the non-indented line
19357        assert_eq!(text, "first\\");
19358    }
19359
19360    #[test]
19361    fn collect_hardbreak_continuations_no_trailing_break() {
19362        // If the text doesn't end with a hardBreak marker, nothing is collected.
19363        let input = "plain\n  indented\n";
19364        let mut parser = MarkdownParser::new(input);
19365        parser.advance();
19366        let mut text = "plain".to_string();
19367        parser.collect_hardbreak_continuations(&mut text);
19368        assert_eq!(text, "plain");
19369    }
19370
19371    #[test]
19372    fn collect_hardbreak_continuations_chained() {
19373        // Multiple continuation lines chained via repeated hardBreaks.
19374        let input = "a\\\n  b\\\n  c\\\n  d\n";
19375        let mut parser = MarkdownParser::new(input);
19376        parser.advance();
19377        let mut text = "a\\".to_string();
19378        parser.collect_hardbreak_continuations(&mut text);
19379        assert_eq!(text, "a\\\nb\\\nc\\\nd");
19380    }
19381
19382    #[test]
19383    fn collect_hardbreak_continuations_stops_before_image_line() {
19384        // An indented continuation that starts with `![` (mediaSingle syntax)
19385        // must NOT be swallowed as a paragraph continuation (issue #490).
19386        let input = "text\\\n  ![](url){type=file id=x}\n";
19387        let mut parser = MarkdownParser::new(input);
19388        parser.advance(); // skip first line
19389        let mut text = "text\\".to_string();
19390        parser.collect_hardbreak_continuations(&mut text);
19391        // The image line should NOT have been consumed.
19392        assert_eq!(text, "text\\");
19393        // Parser should still be on the image line (not past it).
19394        assert!(!parser.at_end());
19395        assert!(parser.current_line().contains("![](url)"));
19396    }
19397
19398    #[test]
19399    fn is_block_level_continuation_marker_positive_cases() {
19400        // Each marker that forces `collect_hardbreak_continuations` to stop.
19401        assert!(is_block_level_continuation_marker("![](url)"));
19402        assert!(is_block_level_continuation_marker("```ruby"));
19403        assert!(is_block_level_continuation_marker(":::panel{type=info}"));
19404    }
19405
19406    #[test]
19407    fn is_block_level_continuation_marker_negative_cases() {
19408        // Plain continuation text must NOT look like a block-level marker.
19409        assert!(!is_block_level_continuation_marker("plain text"));
19410        assert!(!is_block_level_continuation_marker("- nested item"));
19411        assert!(!is_block_level_continuation_marker("continuation\\"));
19412        assert!(!is_block_level_continuation_marker(""));
19413        // Double-colon `::` is not a container directive.
19414        assert!(!is_block_level_continuation_marker("::partial"));
19415        // Single backticks are inline code, not a fence.
19416        assert!(!is_block_level_continuation_marker("`inline`"));
19417    }
19418
19419    #[test]
19420    fn collect_hardbreak_continuations_stops_before_code_fence() {
19421        // Issue #552: An indented continuation that opens a fenced code block
19422        // must NOT be swallowed as a paragraph continuation — it has to stay
19423        // available for `try_code_block` on the next parse iteration.
19424        let input = "text\\\n  ```ruby\n  Foo::Bar::Baz\n  ```\n";
19425        let mut parser = MarkdownParser::new(input);
19426        parser.advance();
19427        let mut text = "text\\".to_string();
19428        parser.collect_hardbreak_continuations(&mut text);
19429        assert_eq!(text, "text\\");
19430        assert!(!parser.at_end());
19431        assert!(parser.current_line().starts_with("  ```"));
19432    }
19433
19434    #[test]
19435    fn collect_hardbreak_continuations_stops_before_container_directive() {
19436        // Issue #552: An indented continuation that opens a `:::` container
19437        // directive (panel, expand, etc.) must also stay available for the
19438        // directive parser.
19439        let input = "text\\\n  :::panel{type=info}\n  body\n  :::\n";
19440        let mut parser = MarkdownParser::new(input);
19441        parser.advance();
19442        let mut text = "text\\".to_string();
19443        parser.collect_hardbreak_continuations(&mut text);
19444        assert_eq!(text, "text\\");
19445        assert!(!parser.at_end());
19446        assert!(parser.current_line().contains(":::panel"));
19447    }
19448
19449    #[test]
19450    fn collect_hardbreak_continuations_stops_before_indented_code_fence() {
19451        // Variant: extra leading whitespace on the code-fence line (so the
19452        // stripped tail is `  ```` rather than a bare ` ``` `) must still be
19453        // recognised by the `trim_start().starts_with("```")` check.
19454        let input = "text\\\n     ```text\n     :fire:\n     ```\n";
19455        let mut parser = MarkdownParser::new(input);
19456        parser.advance();
19457        let mut text = "text\\".to_string();
19458        parser.collect_hardbreak_continuations(&mut text);
19459        assert_eq!(text, "text\\");
19460        assert!(!parser.at_end());
19461        assert!(parser.current_line().contains("```text"));
19462    }
19463
19464    #[test]
19465    fn ordered_list_with_sub_content_after_hardbreak() {
19466        // Exercises the sub-content collection loop in parse_ordered_list
19467        // (lines 339-347) with a hardBreak item that also has a nested list.
19468        let adf_json = r#"{"version":1,"type":"doc","content":[
19469          {"type":"orderedList","attrs":{"order":1},"content":[
19470            {"type":"listItem","content":[
19471              {"type":"paragraph","content":[
19472                {"type":"text","text":"parent"},
19473                {"type":"hardBreak"},
19474                {"type":"text","text":"continued"}
19475              ]},
19476              {"type":"bulletList","content":[
19477                {"type":"listItem","content":[
19478                  {"type":"paragraph","content":[
19479                    {"type":"text","text":"child"}
19480                  ]}
19481                ]}
19482              ]}
19483            ]}
19484          ]}
19485        ]}"#;
19486        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19487        let md = adf_to_markdown(&doc).unwrap();
19488        let rt = markdown_to_adf(&md).unwrap();
19489
19490        assert_eq!(rt.content.len(), 1);
19491        assert_eq!(rt.content[0].node_type, "orderedList");
19492        let item_content = rt.content[0].content.as_ref().unwrap()[0]
19493            .content
19494            .as_ref()
19495            .unwrap();
19496        // Paragraph with hardBreak
19497        let p = item_content[0].content.as_ref().unwrap();
19498        let types: Vec<&str> = p.iter().map(|n| n.node_type.as_str()).collect();
19499        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19500        assert_eq!(p[0].text.as_deref(), Some("parent"));
19501        assert_eq!(p[2].text.as_deref(), Some("continued"));
19502        // Nested bullet list
19503        assert_eq!(item_content[1].node_type, "bulletList");
19504    }
19505
19506    #[test]
19507    fn render_list_item_content_no_content() {
19508        // A listItem with content: None should produce just a newline.
19509        let item = AdfNode {
19510            node_type: "listItem".to_string(),
19511            attrs: None,
19512            content: None,
19513            text: None,
19514            marks: None,
19515            local_id: None,
19516            parameters: None,
19517        };
19518        let mut output = String::new();
19519        let opts = RenderOptions::default();
19520        render_list_item_content(&item, &mut output, &opts);
19521        assert_eq!(output, "\n");
19522    }
19523
19524    #[test]
19525    fn render_list_item_content_empty_content() {
19526        // A listItem with content: Some(vec![]) should produce just a newline.
19527        let item = AdfNode::list_item(vec![]);
19528        let mut output = String::new();
19529        let opts = RenderOptions::default();
19530        render_list_item_content(&item, &mut output, &opts);
19531        assert_eq!(output, "\n");
19532    }
19533
19534    #[test]
19535    fn plus_bullet_paragraph_roundtrips() {
19536        // Paragraph starting with "+ " must round-trip without becoming
19537        // a bullet list.
19538        let adf_json = r#"{"version":1,"type":"doc","content":[
19539          {"type":"paragraph","content":[
19540            {"type":"text","text":"+ plus"}
19541          ]}
19542        ]}"#;
19543        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19544        let md = adf_to_markdown(&doc).unwrap();
19545        let rt = markdown_to_adf(&md).unwrap();
19546        assert_eq!(rt.content[0].node_type, "paragraph");
19547        assert_eq!(
19548            rt.content[0].content.as_ref().unwrap()[0]
19549                .text
19550                .as_deref()
19551                .unwrap(),
19552            "+ plus"
19553        );
19554    }
19555
19556    // ---- Issue #430 tests: mediaSingle inside listItem ----
19557
19558    #[test]
19559    fn issue_430_file_media_in_bullet_list_roundtrip() {
19560        // Issue #430: mediaSingle (type:file) as direct child of listItem
19561        // in a bulletList must survive round-trip.
19562        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19563          {"type":"listItem","content":[{
19564            "type":"mediaSingle",
19565            "attrs":{"layout":"center","width":1009,"widthType":"pixel"},
19566            "content":[{
19567              "type":"media",
19568              "attrs":{"collection":"contentId-123","height":576,"id":"00066e8e-554e-4d7e-af59-a0ef2888bdb6","type":"file","width":1009}
19569            }]
19570          }]}
19571        ]}]}"#;
19572        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19573        let md = adf_to_markdown(&doc).unwrap();
19574        let rt = markdown_to_adf(&md).unwrap();
19575
19576        let list = &rt.content[0];
19577        assert_eq!(list.node_type, "bulletList");
19578        let item = &list.content.as_ref().unwrap()[0];
19579        assert_eq!(item.node_type, "listItem");
19580        let ms = &item.content.as_ref().unwrap()[0];
19581        assert_eq!(ms.node_type, "mediaSingle");
19582        let ms_attrs = ms.attrs.as_ref().unwrap();
19583        assert_eq!(ms_attrs["layout"], "center");
19584        assert_eq!(ms_attrs["width"], 1009);
19585        assert_eq!(ms_attrs["widthType"], "pixel");
19586        let media = &ms.content.as_ref().unwrap()[0];
19587        assert_eq!(media.node_type, "media");
19588        let m_attrs = media.attrs.as_ref().unwrap();
19589        assert_eq!(m_attrs["type"], "file");
19590        assert_eq!(m_attrs["id"], "00066e8e-554e-4d7e-af59-a0ef2888bdb6");
19591        assert_eq!(m_attrs["collection"], "contentId-123");
19592        assert_eq!(m_attrs["height"], 576);
19593        assert_eq!(m_attrs["width"], 1009);
19594    }
19595
19596    #[test]
19597    fn issue_430_file_media_in_ordered_list_roundtrip() {
19598        // Same as above but inside an orderedList.
19599        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
19600          {"type":"listItem","content":[{
19601            "type":"mediaSingle",
19602            "attrs":{"layout":"center"},
19603            "content":[{
19604              "type":"media",
19605              "attrs":{"type":"file","id":"abc-123","collection":"contentId-456","height":100,"width":200}
19606            }]
19607          }]}
19608        ]}]}"#;
19609        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19610        let md = adf_to_markdown(&doc).unwrap();
19611        let rt = markdown_to_adf(&md).unwrap();
19612
19613        let list = &rt.content[0];
19614        assert_eq!(list.node_type, "orderedList");
19615        let item = &list.content.as_ref().unwrap()[0];
19616        assert_eq!(item.node_type, "listItem");
19617        let ms = &item.content.as_ref().unwrap()[0];
19618        assert_eq!(ms.node_type, "mediaSingle");
19619        let media = &ms.content.as_ref().unwrap()[0];
19620        assert_eq!(media.node_type, "media");
19621        let m_attrs = media.attrs.as_ref().unwrap();
19622        assert_eq!(m_attrs["type"], "file");
19623        assert_eq!(m_attrs["id"], "abc-123");
19624        assert_eq!(m_attrs["collection"], "contentId-456");
19625    }
19626
19627    #[test]
19628    fn issue_430_external_media_in_bullet_list_roundtrip() {
19629        // External image (type:external) inside a bullet list item.
19630        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19631          {"type":"listItem","content":[{
19632            "type":"mediaSingle",
19633            "attrs":{"layout":"center"},
19634            "content":[{
19635              "type":"media",
19636              "attrs":{"type":"external","url":"https://example.com/img.png","alt":"Photo"}
19637            }]
19638          }]}
19639        ]}]}"#;
19640        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19641        let md = adf_to_markdown(&doc).unwrap();
19642        let rt = markdown_to_adf(&md).unwrap();
19643
19644        let list = &rt.content[0];
19645        assert_eq!(list.node_type, "bulletList");
19646        let item = &list.content.as_ref().unwrap()[0];
19647        let ms = &item.content.as_ref().unwrap()[0];
19648        assert_eq!(ms.node_type, "mediaSingle");
19649        let media = &ms.content.as_ref().unwrap()[0];
19650        assert_eq!(media.node_type, "media");
19651        let m_attrs = media.attrs.as_ref().unwrap();
19652        assert_eq!(m_attrs["type"], "external");
19653        assert_eq!(m_attrs["url"], "https://example.com/img.png");
19654    }
19655
19656    #[test]
19657    fn issue_430_media_with_paragraph_siblings_in_list_item() {
19658        // listItem containing a paragraph followed by a mediaSingle.
19659        // Both children must survive round-trip.
19660        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19661          {"type":"listItem","content":[
19662            {"type":"paragraph","content":[{"type":"text","text":"Caption:"}]},
19663            {"type":"mediaSingle","attrs":{"layout":"center"},
19664             "content":[{"type":"media","attrs":{"type":"file","id":"img-001","collection":"col-1","height":50,"width":100}}]}
19665          ]}
19666        ]}]}"#;
19667        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19668        let md = adf_to_markdown(&doc).unwrap();
19669        let rt = markdown_to_adf(&md).unwrap();
19670
19671        let item = &rt.content[0].content.as_ref().unwrap()[0];
19672        let children = item.content.as_ref().unwrap();
19673        assert_eq!(children.len(), 2, "expected 2 children in listItem");
19674        assert_eq!(children[0].node_type, "paragraph");
19675        assert_eq!(children[1].node_type, "mediaSingle");
19676        let media = &children[1].content.as_ref().unwrap()[0];
19677        assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-001");
19678    }
19679
19680    #[test]
19681    fn issue_430_multiple_media_in_list_items() {
19682        // Multiple list items each containing mediaSingle.
19683        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19684          {"type":"listItem","content":[{
19685            "type":"mediaSingle","attrs":{"layout":"center"},
19686            "content":[{"type":"media","attrs":{"type":"file","id":"img-a","collection":"c1","height":10,"width":20}}]
19687          }]},
19688          {"type":"listItem","content":[{
19689            "type":"mediaSingle","attrs":{"layout":"center"},
19690            "content":[{"type":"media","attrs":{"type":"file","id":"img-b","collection":"c2","height":30,"width":40}}]
19691          }]}
19692        ]}]}"#;
19693        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19694        let md = adf_to_markdown(&doc).unwrap();
19695        let rt = markdown_to_adf(&md).unwrap();
19696
19697        let items = rt.content[0].content.as_ref().unwrap();
19698        assert_eq!(items.len(), 2);
19699        for (i, expected_id) in [("img-a", "c1"), ("img-b", "c2")].iter().enumerate() {
19700            let ms = &items[i].content.as_ref().unwrap()[0];
19701            assert_eq!(ms.node_type, "mediaSingle");
19702            let m_attrs = ms.content.as_ref().unwrap()[0].attrs.as_ref().unwrap();
19703            assert_eq!(m_attrs["id"], expected_id.0);
19704            assert_eq!(m_attrs["collection"], expected_id.1);
19705        }
19706    }
19707
19708    #[test]
19709    fn issue_430_jfm_to_adf_media_in_bullet_item() {
19710        // Parse JFM directly: image syntax on the first line of a bullet item
19711        // must produce mediaSingle, not a paragraph with corrupted text.
19712        let md = "- ![](){type=file id=test-id collection=col-1 height=100 width=200}\n";
19713        let doc = markdown_to_adf(md).unwrap();
19714
19715        let list = &doc.content[0];
19716        assert_eq!(list.node_type, "bulletList");
19717        let item = &list.content.as_ref().unwrap()[0];
19718        let ms = &item.content.as_ref().unwrap()[0];
19719        assert_eq!(
19720            ms.node_type, "mediaSingle",
19721            "expected mediaSingle, got {}",
19722            ms.node_type
19723        );
19724        let media = &ms.content.as_ref().unwrap()[0];
19725        assert_eq!(media.node_type, "media");
19726        let m_attrs = media.attrs.as_ref().unwrap();
19727        assert_eq!(m_attrs["type"], "file");
19728        assert_eq!(m_attrs["id"], "test-id");
19729    }
19730
19731    #[test]
19732    fn issue_430_jfm_to_adf_media_in_ordered_item() {
19733        // Parse JFM directly: image syntax on the first line of an ordered list item.
19734        let md = "1. ![alt text](https://example.com/photo.jpg)\n";
19735        let doc = markdown_to_adf(md).unwrap();
19736
19737        let list = &doc.content[0];
19738        assert_eq!(list.node_type, "orderedList");
19739        let item = &list.content.as_ref().unwrap()[0];
19740        let ms = &item.content.as_ref().unwrap()[0];
19741        assert_eq!(
19742            ms.node_type, "mediaSingle",
19743            "expected mediaSingle, got {}",
19744            ms.node_type
19745        );
19746    }
19747
19748    #[test]
19749    fn issue_430_media_then_paragraph_in_bullet_list_roundtrip() {
19750        // listItem with mediaSingle as first child followed by a paragraph.
19751        // Exercises the sub_lines non-empty path when first_node is mediaSingle.
19752        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19753          {"type":"listItem","content":[
19754            {"type":"mediaSingle","attrs":{"layout":"center"},
19755             "content":[{"type":"media","attrs":{"type":"file","id":"img-first","collection":"col-1","height":50,"width":100}}]},
19756            {"type":"paragraph","content":[{"type":"text","text":"Caption below"}]}
19757          ]}
19758        ]}]}"#;
19759        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19760        let md = adf_to_markdown(&doc).unwrap();
19761        let rt = markdown_to_adf(&md).unwrap();
19762
19763        let item = &rt.content[0].content.as_ref().unwrap()[0];
19764        let children = item.content.as_ref().unwrap();
19765        assert_eq!(children.len(), 2, "expected 2 children in listItem");
19766        assert_eq!(children[0].node_type, "mediaSingle");
19767        let media = &children[0].content.as_ref().unwrap()[0];
19768        assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-first");
19769        assert_eq!(children[1].node_type, "paragraph");
19770    }
19771
19772    #[test]
19773    fn issue_430_media_then_paragraph_in_ordered_list_roundtrip() {
19774        // Same as above but for ordered lists.
19775        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
19776          {"type":"listItem","content":[
19777            {"type":"mediaSingle","attrs":{"layout":"center"},
19778             "content":[{"type":"media","attrs":{"type":"file","id":"img-ord","collection":"col-2","height":60,"width":120}}]},
19779            {"type":"paragraph","content":[{"type":"text","text":"Description"}]}
19780          ]}
19781        ]}]}"#;
19782        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19783        let md = adf_to_markdown(&doc).unwrap();
19784        let rt = markdown_to_adf(&md).unwrap();
19785
19786        let item = &rt.content[0].content.as_ref().unwrap()[0];
19787        let children = item.content.as_ref().unwrap();
19788        assert_eq!(children.len(), 2, "expected 2 children in listItem");
19789        assert_eq!(children[0].node_type, "mediaSingle");
19790        assert_eq!(children[1].node_type, "paragraph");
19791    }
19792
19793    #[test]
19794    fn issue_430_external_media_with_width_type_roundtrip() {
19795        // External image with widthType attr must survive round-trip.
19796        let adf_json = r#"{"version":1,"type":"doc","content":[{
19797          "type":"mediaSingle",
19798          "attrs":{"layout":"wide","width":800,"widthType":"pixel"},
19799          "content":[{
19800            "type":"media",
19801            "attrs":{"type":"external","url":"https://example.com/photo.png","alt":"wide photo"}
19802          }]
19803        }]}"#;
19804        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19805        let md = adf_to_markdown(&doc).unwrap();
19806        assert!(
19807            md.contains("widthType=pixel"),
19808            "expected widthType=pixel in markdown, got: {md}"
19809        );
19810        let rt = markdown_to_adf(&md).unwrap();
19811        let ms = &rt.content[0];
19812        assert_eq!(ms.node_type, "mediaSingle");
19813        let ms_attrs = ms.attrs.as_ref().unwrap();
19814        assert_eq!(ms_attrs["widthType"], "pixel");
19815        assert_eq!(ms_attrs["width"], 800);
19816        assert_eq!(ms_attrs["layout"], "wide");
19817    }
19818
19819    // ── Issue #490: mediaSingle after hardBreak in listItem ─────
19820
19821    #[test]
19822    fn issue_490_paragraph_with_hardbreak_then_media_single_roundtrip() {
19823        // Reproducer from issue #490: paragraph with trailing hardBreak
19824        // followed by mediaSingle inside a listItem.
19825        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19826          {"type":"listItem","content":[
19827            {"type":"paragraph","content":[
19828              {"type":"text","text":"Item with image:"},
19829              {"type":"hardBreak"}
19830            ]},
19831            {"type":"mediaSingle","attrs":{"layout":"center","width":400,"widthType":"pixel"},
19832             "content":[{"type":"media","attrs":{
19833               "id":"aabbccdd-1234-5678-abcd-aabbccdd1234",
19834               "type":"file",
19835               "collection":"contentId-123456",
19836               "width":800,
19837               "height":600
19838             }}]}
19839          ]}
19840        ]}]}"#;
19841        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19842        let md = adf_to_markdown(&doc).unwrap();
19843        let rt = markdown_to_adf(&md).unwrap();
19844
19845        let item = &rt.content[0].content.as_ref().unwrap()[0];
19846        let children = item.content.as_ref().unwrap();
19847        assert_eq!(children.len(), 2, "expected 2 children in listItem");
19848        assert_eq!(children[0].node_type, "paragraph");
19849        assert_eq!(
19850            children[1].node_type, "mediaSingle",
19851            "expected mediaSingle, got {:?}",
19852            children[1].node_type
19853        );
19854        let media = &children[1].content.as_ref().unwrap()[0];
19855        let m_attrs = media.attrs.as_ref().unwrap();
19856        assert_eq!(m_attrs["id"], "aabbccdd-1234-5678-abcd-aabbccdd1234");
19857        assert_eq!(m_attrs["collection"], "contentId-123456");
19858        assert_eq!(m_attrs["height"], 600);
19859        assert_eq!(m_attrs["width"], 800);
19860    }
19861
19862    #[test]
19863    fn issue_490_paragraph_with_hardbreak_then_media_single_ordered_list() {
19864        // Same scenario but in an ordered list.
19865        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
19866          {"type":"listItem","content":[
19867            {"type":"paragraph","content":[
19868              {"type":"text","text":"Step with screenshot:"},
19869              {"type":"hardBreak"}
19870            ]},
19871            {"type":"mediaSingle","attrs":{"layout":"center"},
19872             "content":[{"type":"media","attrs":{
19873               "id":"ord-media-id","type":"file","collection":"col-ord","width":640,"height":480
19874             }}]}
19875          ]}
19876        ]}]}"#;
19877        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19878        let md = adf_to_markdown(&doc).unwrap();
19879        let rt = markdown_to_adf(&md).unwrap();
19880
19881        let item = &rt.content[0].content.as_ref().unwrap()[0];
19882        let children = item.content.as_ref().unwrap();
19883        assert_eq!(children.len(), 2, "expected 2 children in listItem");
19884        assert_eq!(children[0].node_type, "paragraph");
19885        assert_eq!(children[1].node_type, "mediaSingle");
19886        let media = &children[1].content.as_ref().unwrap()[0];
19887        assert_eq!(media.attrs.as_ref().unwrap()["id"], "ord-media-id");
19888    }
19889
19890    #[test]
19891    fn issue_490_hardbreak_continuation_does_not_swallow_media_line() {
19892        // Directly tests that collect_hardbreak_continuations stops before
19893        // an indented mediaSingle line.
19894        let md = "- Item with image:\\\n  ![](){type=file id=test-490 collection=col height=100 width=200}\n";
19895        let doc = markdown_to_adf(md).unwrap();
19896
19897        let item = &doc.content[0].content.as_ref().unwrap()[0];
19898        let children = item.content.as_ref().unwrap();
19899        assert_eq!(children.len(), 2, "expected 2 children in listItem");
19900        assert_eq!(children[0].node_type, "paragraph");
19901        assert_eq!(
19902            children[1].node_type, "mediaSingle",
19903            "expected mediaSingle as second child, got {:?}",
19904            children[1].node_type
19905        );
19906        let media = &children[1].content.as_ref().unwrap()[0];
19907        assert_eq!(media.attrs.as_ref().unwrap()["id"], "test-490");
19908    }
19909
19910    #[test]
19911    fn issue_490_hardbreak_continuation_still_works_for_text() {
19912        // Ensure regular hardBreak continuations still work after the fix.
19913        let md = "- first line\\\n  second line\n";
19914        let doc = markdown_to_adf(md).unwrap();
19915
19916        let item = &doc.content[0].content.as_ref().unwrap()[0];
19917        let children = item.content.as_ref().unwrap();
19918        assert_eq!(
19919            children.len(),
19920            1,
19921            "expected 1 child (paragraph) in listItem"
19922        );
19923        assert_eq!(children[0].node_type, "paragraph");
19924        let inlines = children[0].content.as_ref().unwrap();
19925        // Should contain: text("first line"), hardBreak, text("second line")
19926        assert_eq!(inlines.len(), 3);
19927        assert_eq!(inlines[0].node_type, "text");
19928        assert_eq!(inlines[1].node_type, "hardBreak");
19929        assert_eq!(inlines[2].node_type, "text");
19930    }
19931
19932    #[test]
19933    fn issue_490_external_media_after_hardbreak_roundtrip() {
19934        // External image (URL-based) after a paragraph with hardBreak.
19935        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19936          {"type":"listItem","content":[
19937            {"type":"paragraph","content":[
19938              {"type":"text","text":"See image:"},
19939              {"type":"hardBreak"}
19940            ]},
19941            {"type":"mediaSingle","attrs":{"layout":"center"},
19942             "content":[{"type":"media","attrs":{
19943               "type":"external","url":"https://example.com/photo.png","alt":"photo"
19944             }}]}
19945          ]}
19946        ]}]}"#;
19947        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19948        let md = adf_to_markdown(&doc).unwrap();
19949        let rt = markdown_to_adf(&md).unwrap();
19950
19951        let item = &rt.content[0].content.as_ref().unwrap()[0];
19952        let children = item.content.as_ref().unwrap();
19953        assert_eq!(children.len(), 2);
19954        assert_eq!(children[0].node_type, "paragraph");
19955        assert_eq!(children[1].node_type, "mediaSingle");
19956        let media = &children[1].content.as_ref().unwrap()[0];
19957        let m_attrs = media.attrs.as_ref().unwrap();
19958        assert_eq!(m_attrs["url"], "https://example.com/photo.png");
19959    }
19960
19961    #[test]
19962    fn issue_490_multiple_hardbreaks_then_media_single() {
19963        // Paragraph with multiple hardBreaks, then mediaSingle.
19964        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19965          {"type":"listItem","content":[
19966            {"type":"paragraph","content":[
19967              {"type":"text","text":"line one"},
19968              {"type":"hardBreak"},
19969              {"type":"text","text":"line two"},
19970              {"type":"hardBreak"}
19971            ]},
19972            {"type":"mediaSingle","attrs":{"layout":"center"},
19973             "content":[{"type":"media","attrs":{
19974               "type":"file","id":"multi-hb","collection":"col-m","width":320,"height":240
19975             }}]}
19976          ]}
19977        ]}]}"#;
19978        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19979        let md = adf_to_markdown(&doc).unwrap();
19980        let rt = markdown_to_adf(&md).unwrap();
19981
19982        let item = &rt.content[0].content.as_ref().unwrap()[0];
19983        let children = item.content.as_ref().unwrap();
19984        assert_eq!(children.len(), 2, "expected paragraph + mediaSingle");
19985        assert_eq!(children[0].node_type, "paragraph");
19986        assert_eq!(children[1].node_type, "mediaSingle");
19987        let media = &children[1].content.as_ref().unwrap()[0];
19988        assert_eq!(media.attrs.as_ref().unwrap()["id"], "multi-hb");
19989    }
19990
19991    // ── Issue #525: listItem localId dropped when content includes mediaSingle ──
19992
19993    #[test]
19994    fn issue_525_listitem_localid_with_mediasingle_roundtrip() {
19995        // Exact reproducer from issue #525: listItem with UUID localId whose
19996        // content includes mediaSingle + paragraph + nested bulletList.
19997        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"}]}]}]}]}]}]}"#;
19998        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19999        let md = adf_to_markdown(&doc).unwrap();
20000        let rt = markdown_to_adf(&md).unwrap();
20001
20002        let list = &rt.content[0];
20003        assert_eq!(list.node_type, "bulletList");
20004        let item = &list.content.as_ref().unwrap()[0];
20005        // The localId must be preserved on the listItem.
20006        let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20007        assert_eq!(
20008            item_attrs["localId"], "aabbccdd-1234-5678-abcd-000000000001",
20009            "listItem localId must survive round-trip"
20010        );
20011        let children = item.content.as_ref().unwrap();
20012        assert_eq!(
20013            children.len(),
20014            3,
20015            "expected mediaSingle + paragraph + bulletList"
20016        );
20017        assert_eq!(children[0].node_type, "mediaSingle");
20018        assert_eq!(children[1].node_type, "paragraph");
20019        assert_eq!(children[2].node_type, "bulletList");
20020    }
20021
20022    #[test]
20023    fn issue_525_listitem_localid_with_mediasingle_only() {
20024        // Minimal case: listItem with localId whose sole child is mediaSingle.
20025        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20026          {"type":"listItem","attrs":{"localId":"li-media-only"},"content":[
20027            {"type":"mediaSingle","attrs":{"layout":"center"},
20028             "content":[{"type":"media","attrs":{"type":"file","id":"m-001","collection":"c1","height":50,"width":100}}]}
20029          ]}
20030        ]}]}"#;
20031        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20032        let md = adf_to_markdown(&doc).unwrap();
20033        let rt = markdown_to_adf(&md).unwrap();
20034
20035        let item = &rt.content[0].content.as_ref().unwrap()[0];
20036        let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20037        assert_eq!(
20038            item_attrs["localId"], "li-media-only",
20039            "listItem localId must survive when sole child is mediaSingle"
20040        );
20041        assert_eq!(item.content.as_ref().unwrap()[0].node_type, "mediaSingle");
20042    }
20043
20044    #[test]
20045    fn issue_525_listitem_localid_with_external_media() {
20046        // External image (URL-based) as first child with listItem localId.
20047        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20048          {"type":"listItem","attrs":{"localId":"li-ext-media"},"content":[
20049            {"type":"mediaSingle","attrs":{"layout":"center"},
20050             "content":[{"type":"media","attrs":{"type":"external","url":"https://example.com/img.png","alt":"photo"}}]}
20051          ]}
20052        ]}]}"#;
20053        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20054        let md = adf_to_markdown(&doc).unwrap();
20055        let rt = markdown_to_adf(&md).unwrap();
20056
20057        let item = &rt.content[0].content.as_ref().unwrap()[0];
20058        let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20059        assert_eq!(
20060            item_attrs["localId"], "li-ext-media",
20061            "listItem localId must survive with external mediaSingle"
20062        );
20063    }
20064
20065    #[test]
20066    fn issue_525_listitem_localid_with_mediasingle_in_ordered_list() {
20067        // Same bug in an ordered list.
20068        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
20069          {"type":"listItem","attrs":{"localId":"li-ord-media"},"content":[
20070            {"type":"mediaSingle","attrs":{"layout":"center","width":200,"widthType":"pixel"},
20071             "content":[{"type":"media","attrs":{"type":"file","id":"ord-m-001","collection":"col-ord","height":80,"width":160}}]},
20072            {"type":"paragraph","content":[{"type":"text","text":"ordered item text"}]}
20073          ]}
20074        ]}]}"#;
20075        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20076        let md = adf_to_markdown(&doc).unwrap();
20077        let rt = markdown_to_adf(&md).unwrap();
20078
20079        let item = &rt.content[0].content.as_ref().unwrap()[0];
20080        let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20081        assert_eq!(
20082            item_attrs["localId"], "li-ord-media",
20083            "listItem localId must survive in ordered list with mediaSingle"
20084        );
20085        let children = item.content.as_ref().unwrap();
20086        assert_eq!(children[0].node_type, "mediaSingle");
20087        assert_eq!(children[1].node_type, "paragraph");
20088    }
20089
20090    #[test]
20091    fn issue_525_jfm_localid_on_mediasingle_line_parses_correctly() {
20092        // Verify JFM→ADF: trailing {localId=...} on a mediaSingle line
20093        // is assigned to the listItem, not the media node.
20094        let md = "- ![](){type=file id=test-525 collection=col height=100 width=200 mediaWidth=100 widthType=pixel} {localId=li-jfm-525}\n";
20095        let doc = markdown_to_adf(md).unwrap();
20096
20097        let item = &doc.content[0].content.as_ref().unwrap()[0];
20098        let item_attrs = item
20099            .attrs
20100            .as_ref()
20101            .expect("listItem attrs must be present from JFM");
20102        assert_eq!(item_attrs["localId"], "li-jfm-525");
20103        assert_eq!(item.content.as_ref().unwrap()[0].node_type, "mediaSingle");
20104    }
20105
20106    #[test]
20107    fn issue_525_encoding_emits_localid_on_mediasingle_line() {
20108        // Verify the ADF→JFM encoding: localId appears on the same line
20109        // as the mediaSingle image syntax.
20110        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20111          {"type":"listItem","attrs":{"localId":"li-emit-check"},"content":[
20112            {"type":"mediaSingle","attrs":{"layout":"center"},
20113             "content":[{"type":"media","attrs":{"type":"file","id":"m-emit","collection":"c-emit","height":10,"width":20}}]}
20114          ]}
20115        ]}]}"#;
20116        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20117        let md = adf_to_markdown(&doc).unwrap();
20118        assert!(
20119            md.contains("{localId=li-emit-check}"),
20120            "expected localId in JFM output, got: {md}"
20121        );
20122        // The localId must be on the same line as the image
20123        for line in md.lines() {
20124            if line.contains("![") {
20125                assert!(
20126                    line.contains("localId=li-emit-check"),
20127                    "localId must be on the same line as the image: {line}"
20128                );
20129            }
20130        }
20131    }
20132
20133    // ── Placeholder node tests ────────────────────────────────────
20134
20135    #[test]
20136    fn adf_placeholder_to_markdown() {
20137        let doc = AdfDocument {
20138            version: 1,
20139            doc_type: "doc".to_string(),
20140            content: vec![AdfNode::paragraph(vec![AdfNode::placeholder(
20141                "Type something here",
20142            )])],
20143        };
20144        let md = adf_to_markdown(&doc).unwrap();
20145        assert!(
20146            md.contains(":placeholder[Type something here]"),
20147            "expected :placeholder directive, got: {md}"
20148        );
20149    }
20150
20151    #[test]
20152    fn markdown_placeholder_to_adf() {
20153        let doc = markdown_to_adf("Before :placeholder[Enter name] after").unwrap();
20154        let content = doc.content[0].content.as_ref().unwrap();
20155        assert_eq!(content[1].node_type, "placeholder");
20156        let attrs = content[1].attrs.as_ref().unwrap();
20157        assert_eq!(attrs["text"], "Enter name");
20158    }
20159
20160    #[test]
20161    fn placeholder_round_trip() {
20162        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"placeholder","attrs":{"text":"Type something here"}}]}]}"#;
20163        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20164        let md = adf_to_markdown(&doc).unwrap();
20165        let rt = markdown_to_adf(&md).unwrap();
20166        let content = rt.content[0].content.as_ref().unwrap();
20167        assert_eq!(content.len(), 1);
20168        assert_eq!(content[0].node_type, "placeholder");
20169        let attrs = content[0].attrs.as_ref().unwrap();
20170        assert_eq!(attrs["text"], "Type something here");
20171    }
20172
20173    #[test]
20174    fn placeholder_empty_text() {
20175        let doc = AdfDocument {
20176            version: 1,
20177            doc_type: "doc".to_string(),
20178            content: vec![AdfNode::paragraph(vec![AdfNode::placeholder("")])],
20179        };
20180        let md = adf_to_markdown(&doc).unwrap();
20181        assert!(
20182            md.contains(":placeholder[]"),
20183            "expected empty placeholder directive, got: {md}"
20184        );
20185        let rt = markdown_to_adf(&md).unwrap();
20186        let content = rt.content[0].content.as_ref().unwrap();
20187        assert_eq!(content[0].node_type, "placeholder");
20188        assert_eq!(content[0].attrs.as_ref().unwrap()["text"], "");
20189    }
20190
20191    #[test]
20192    fn placeholder_with_surrounding_text() {
20193        let md = "Click :placeholder[here] to continue\n";
20194        let doc = markdown_to_adf(md).unwrap();
20195        let content = doc.content[0].content.as_ref().unwrap();
20196        assert_eq!(content[0].text.as_deref(), Some("Click "));
20197        assert_eq!(content[1].node_type, "placeholder");
20198        assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "here");
20199        assert_eq!(content[2].text.as_deref(), Some(" to continue"));
20200    }
20201
20202    #[test]
20203    fn placeholder_missing_attrs() {
20204        // Placeholder node with no attrs should not panic
20205        let doc = AdfDocument {
20206            version: 1,
20207            doc_type: "doc".to_string(),
20208            content: vec![AdfNode::paragraph(vec![AdfNode {
20209                node_type: "placeholder".to_string(),
20210                attrs: None,
20211                content: None,
20212                text: None,
20213                marks: None,
20214                local_id: None,
20215                parameters: None,
20216            }])],
20217        };
20218        let md = adf_to_markdown(&doc).unwrap();
20219        // With no attrs, nothing is emitted for the placeholder
20220        assert!(!md.contains("placeholder"));
20221    }
20222
20223    // Issue #446: mention in table+list loses id and misplaces localId
20224    #[test]
20225    fn mention_in_table_bullet_list_preserves_id_and_local_id() {
20226        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":" "}]}]}]}]}]}]}]}"#;
20227        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20228        let md = adf_to_markdown(&doc).unwrap();
20229        let rt = markdown_to_adf(&md).unwrap();
20230
20231        // Navigate: doc → table → tableRow → tableCell → bulletList → listItem → paragraph
20232        let cell = &rt.content[0].content.as_ref().unwrap()[0]
20233            .content
20234            .as_ref()
20235            .unwrap()[0];
20236        let list = &cell.content.as_ref().unwrap()[0];
20237        let list_item = &list.content.as_ref().unwrap()[0];
20238
20239        // listItem must NOT have a localId attribute
20240        assert!(
20241            list_item
20242                .attrs
20243                .as_ref()
20244                .and_then(|a| a.get("localId"))
20245                .is_none(),
20246            "localId should stay on the mention, not the listItem"
20247        );
20248
20249        let para = &list_item.content.as_ref().unwrap()[0];
20250        let inlines = para.content.as_ref().unwrap();
20251
20252        // Should have: text("prefix text "), mention, text(" ")
20253        assert_eq!(inlines.len(), 3, "expected 3 inline nodes, got {inlines:?}");
20254
20255        assert_eq!(inlines[0].node_type, "text");
20256        assert_eq!(inlines[0].text.as_deref(), Some("prefix text "));
20257
20258        assert_eq!(inlines[1].node_type, "mention");
20259        let mention_attrs = inlines[1].attrs.as_ref().unwrap();
20260        assert_eq!(
20261            mention_attrs["id"], "aabbccdd11223344aabbccdd",
20262            "mention id must be preserved"
20263        );
20264        assert_eq!(
20265            mention_attrs["localId"], "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
20266            "mention localId must be preserved"
20267        );
20268        assert_eq!(mention_attrs["text"], "@Alice Example");
20269
20270        assert_eq!(inlines[2].node_type, "text");
20271        assert_eq!(inlines[2].text.as_deref(), Some(" "));
20272    }
20273
20274    #[test]
20275    fn mention_in_bullet_list_preserves_id_and_local_id() {
20276        // Same bug outside of a table context
20277        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":" "}]}]}]}]}"#;
20278        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20279        let md = adf_to_markdown(&doc).unwrap();
20280        let rt = markdown_to_adf(&md).unwrap();
20281
20282        let list_item = &rt.content[0].content.as_ref().unwrap()[0];
20283        assert!(
20284            list_item
20285                .attrs
20286                .as_ref()
20287                .and_then(|a| a.get("localId"))
20288                .is_none(),
20289            "localId should stay on the mention, not the listItem"
20290        );
20291
20292        let para = &list_item.content.as_ref().unwrap()[0];
20293        let inlines = para.content.as_ref().unwrap();
20294        assert_eq!(inlines[0].node_type, "mention");
20295        let mention_attrs = inlines[0].attrs.as_ref().unwrap();
20296        assert_eq!(mention_attrs["id"], "user123");
20297        assert_eq!(
20298            mention_attrs["localId"],
20299            "11111111-2222-3333-4444-555555555555"
20300        );
20301    }
20302
20303    #[test]
20304    fn mention_in_ordered_list_preserves_id_and_local_id() {
20305        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"}}]}]}]}]}"#;
20306        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20307        let md = adf_to_markdown(&doc).unwrap();
20308        let rt = markdown_to_adf(&md).unwrap();
20309
20310        let list_item = &rt.content[0].content.as_ref().unwrap()[0];
20311        assert!(
20312            list_item
20313                .attrs
20314                .as_ref()
20315                .and_then(|a| a.get("localId"))
20316                .is_none(),
20317            "localId should stay on the mention, not the listItem"
20318        );
20319
20320        let para = &list_item.content.as_ref().unwrap()[0];
20321        let inlines = para.content.as_ref().unwrap();
20322        assert_eq!(inlines[1].node_type, "mention");
20323        let mention_attrs = inlines[1].attrs.as_ref().unwrap();
20324        assert_eq!(mention_attrs["id"], "xyz");
20325        assert_eq!(mention_attrs["localId"], "aaaa-bbbb");
20326    }
20327
20328    #[test]
20329    fn list_item_own_local_id_with_mention_both_preserved() {
20330        // When a listItem has its own localId AND contains a mention with localId,
20331        // both should be preserved independently.
20332        let md = "- hello :mention[@Eve]{id=e1 localId=mention-lid} {localId=item-lid}\n";
20333        let doc = markdown_to_adf(md).unwrap();
20334        let list_item = &doc.content[0].content.as_ref().unwrap()[0];
20335
20336        // listItem should have its own localId
20337        let item_attrs = list_item.attrs.as_ref().unwrap();
20338        assert_eq!(item_attrs["localId"], "item-lid");
20339
20340        // mention should have its own localId
20341        let para = &list_item.content.as_ref().unwrap()[0];
20342        let inlines = para.content.as_ref().unwrap();
20343        let mention = inlines.iter().find(|n| n.node_type == "mention").unwrap();
20344        let mention_attrs = mention.attrs.as_ref().unwrap();
20345        assert_eq!(mention_attrs["id"], "e1");
20346        assert_eq!(mention_attrs["localId"], "mention-lid");
20347    }
20348
20349    #[test]
20350    fn extract_trailing_local_id_ignores_directive_attrs() {
20351        // Directly test the helper: a line ending with a directive's {…}
20352        // should NOT be treated as a trailing localId.
20353        let line = "text :mention[@X]{id=abc localId=uuid}";
20354        let (text, lid, plid) = extract_trailing_local_id(line);
20355        assert_eq!(text, line, "text should be unchanged");
20356        assert!(
20357            lid.is_none(),
20358            "should not extract localId from directive attrs"
20359        );
20360        assert!(plid.is_none());
20361    }
20362
20363    #[test]
20364    fn extract_trailing_local_id_matches_standalone_block() {
20365        // A standalone trailing {localId=…} separated by whitespace should still work.
20366        let line = "some text {localId=abc-123}";
20367        let (text, lid, plid) = extract_trailing_local_id(line);
20368        assert_eq!(text, "some text");
20369        assert_eq!(lid.as_deref(), Some("abc-123"));
20370        assert!(plid.is_none());
20371    }
20372
20373    // --- Issue #454: literal newline in text node inside listItem paragraph ---
20374
20375    #[test]
20376    fn newline_in_text_node_roundtrips_in_bullet_list() {
20377        // A text node containing a literal \n inside a bullet list item
20378        // must round-trip as a single text node with the embedded newline
20379        // preserved, not split into multiple paragraphs or hardBreak nodes.
20380        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"}]}]}]}]}"#;
20381        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20382        let md = adf_to_markdown(&doc).unwrap();
20383        let rt = markdown_to_adf(&md).unwrap();
20384
20385        // Should still be a single bulletList with one listItem
20386        assert_eq!(rt.content.len(), 1);
20387        let list = &rt.content[0];
20388        assert_eq!(list.node_type, "bulletList");
20389        let items = list.content.as_ref().unwrap();
20390        assert_eq!(items.len(), 1);
20391
20392        // The listItem should have exactly one paragraph child
20393        let item_content = items[0].content.as_ref().unwrap();
20394        assert_eq!(
20395            item_content.len(),
20396            1,
20397            "listItem should have exactly one paragraph"
20398        );
20399        assert_eq!(item_content[0].node_type, "paragraph");
20400
20401        // The embedded newline must survive as a single text node
20402        let inlines = item_content[0].content.as_ref().unwrap();
20403        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20404        assert_eq!(
20405            types,
20406            vec!["text", "hardBreak", "text"],
20407            "embedded newline should stay in a single text node, not produce extra hardBreaks"
20408        );
20409        assert_eq!(
20410            inlines[2].text.as_deref(),
20411            Some("first command\nsecond command")
20412        );
20413    }
20414
20415    #[test]
20416    fn newline_in_text_node_roundtrips_in_ordered_list() {
20417        // Same as above but in an ordered list.
20418        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"}]}]}]}]}"#;
20419        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20420        let md = adf_to_markdown(&doc).unwrap();
20421        let rt = markdown_to_adf(&md).unwrap();
20422
20423        let list = &rt.content[0];
20424        assert_eq!(list.node_type, "orderedList");
20425        let items = list.content.as_ref().unwrap();
20426        assert_eq!(items.len(), 1);
20427
20428        let item_content = items[0].content.as_ref().unwrap();
20429        assert_eq!(item_content.len(), 1);
20430        assert_eq!(item_content[0].node_type, "paragraph");
20431
20432        let inlines = item_content[0].content.as_ref().unwrap();
20433        assert_eq!(inlines.len(), 1);
20434        assert_eq!(inlines[0].node_type, "text");
20435        assert_eq!(inlines[0].text.as_deref(), Some("first\nsecond"));
20436    }
20437
20438    #[test]
20439    fn newline_in_text_node_roundtrips_in_paragraph() {
20440        // A text node with \n in a top-level paragraph should render as
20441        // escaped \n and round-trip back to a single text node.
20442        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello\nworld"}]}]}"#;
20443        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20444        let md = adf_to_markdown(&doc).unwrap();
20445        assert!(
20446            md.contains("hello\\nworld"),
20447            "newline in text node should render as escaped \\n: {md:?}"
20448        );
20449
20450        let rt = markdown_to_adf(&md).unwrap();
20451        let inlines = rt.content[0].content.as_ref().unwrap();
20452        assert_eq!(inlines.len(), 1);
20453        assert_eq!(inlines[0].text.as_deref(), Some("hello\nworld"));
20454    }
20455
20456    #[test]
20457    fn multiple_newlines_in_text_node_roundtrip() {
20458        // Multiple \n characters should each round-trip within the same text node.
20459        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a\nb\nc"}]}]}]}]}"#;
20460        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20461        let md = adf_to_markdown(&doc).unwrap();
20462        let rt = markdown_to_adf(&md).unwrap();
20463
20464        let item_content = rt.content[0].content.as_ref().unwrap()[0]
20465            .content
20466            .as_ref()
20467            .unwrap();
20468        assert_eq!(item_content.len(), 1);
20469
20470        let inlines = item_content[0].content.as_ref().unwrap();
20471        assert_eq!(inlines.len(), 1);
20472        assert_eq!(inlines[0].text.as_deref(), Some("a\nb\nc"));
20473    }
20474
20475    #[test]
20476    fn newline_in_marked_text_node_roundtrips() {
20477        // A bold text node with \n should round-trip preserving both
20478        // the marks and the embedded newline.
20479        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"bold\ntext","marks":[{"type":"strong"}]}]}]}"#;
20480        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20481        let md = adf_to_markdown(&doc).unwrap();
20482        assert!(
20483            md.contains("**bold\\ntext**"),
20484            "bold text with embedded newline should stay in one marked run: {md:?}"
20485        );
20486
20487        let rt = markdown_to_adf(&md).unwrap();
20488        let inlines = rt.content[0].content.as_ref().unwrap();
20489        assert_eq!(inlines.len(), 1);
20490        assert_eq!(inlines[0].text.as_deref(), Some("bold\ntext"));
20491        assert!(inlines[0]
20492            .marks
20493            .as_ref()
20494            .unwrap()
20495            .iter()
20496            .any(|m| m.mark_type == "strong"));
20497    }
20498
20499    #[test]
20500    fn trailing_newline_in_text_node_roundtrips() {
20501        // A text node ending with \n should round-trip preserving the
20502        // trailing newline.
20503        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"trailing\n"}]}]}"#;
20504        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20505        let md = adf_to_markdown(&doc).unwrap();
20506        assert!(
20507            md.contains("trailing\\n"),
20508            "trailing newline should be escaped: {md:?}"
20509        );
20510
20511        let rt = markdown_to_adf(&md).unwrap();
20512        let inlines = rt.content[0].content.as_ref().unwrap();
20513        assert_eq!(inlines.len(), 1);
20514        assert_eq!(inlines[0].text.as_deref(), Some("trailing\n"));
20515    }
20516
20517    #[test]
20518    fn hardbreak_and_embedded_newline_are_distinct() {
20519        // A hardBreak node and an embedded \n in a text node must not be
20520        // conflated — each must round-trip to its original form.
20521        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"}]}]}"#;
20522        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20523        let md = adf_to_markdown(&doc).unwrap();
20524        let rt = markdown_to_adf(&md).unwrap();
20525
20526        let inlines = rt.content[0].content.as_ref().unwrap();
20527        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20528        assert_eq!(
20529            types,
20530            vec!["text", "hardBreak", "text", "hardBreak", "text"]
20531        );
20532        assert_eq!(inlines[0].text.as_deref(), Some("before"));
20533        assert_eq!(inlines[2].text.as_deref(), Some("mid\ndle"));
20534        assert_eq!(inlines[4].text.as_deref(), Some("after"));
20535    }
20536
20537    // ---- Issue #472 tests ----
20538
20539    #[test]
20540    fn issue_472_bullet_list_trailing_hardbreak_roundtrips() {
20541        // Issue #472: trailing hardBreak at end of listItem paragraph must
20542        // not split the parent bulletList on round-trip.
20543        let adf_json = r#"{"version":1,"type":"doc","content":[
20544          {"type":"bulletList","content":[
20545            {"type":"listItem","content":[
20546              {"type":"paragraph","content":[
20547                {"type":"text","text":"First item"},
20548                {"type":"hardBreak"}
20549              ]}
20550            ]},
20551            {"type":"listItem","content":[
20552              {"type":"paragraph","content":[
20553                {"type":"text","text":"Second item"}
20554              ]}
20555            ]}
20556          ]}
20557        ]}"#;
20558        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20559        let md = adf_to_markdown(&doc).unwrap();
20560        let rt = markdown_to_adf(&md).unwrap();
20561
20562        // Must remain a single bulletList
20563        assert_eq!(
20564            rt.content.len(),
20565            1,
20566            "Should be 1 block (bulletList), got {}",
20567            rt.content.len()
20568        );
20569        assert_eq!(rt.content[0].node_type, "bulletList");
20570        let items = rt.content[0].content.as_ref().unwrap();
20571        assert_eq!(
20572            items.len(),
20573            2,
20574            "Should have 2 listItems, got {}",
20575            items.len()
20576        );
20577
20578        // First item: text + hardBreak (trailing)
20579        let p1 = items[0].content.as_ref().unwrap()[0]
20580            .content
20581            .as_ref()
20582            .unwrap();
20583        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
20584        assert_eq!(types1, vec!["text", "hardBreak"]);
20585        assert_eq!(p1[0].text.as_deref(), Some("First item"));
20586
20587        // Second item: text only
20588        let p2 = items[1].content.as_ref().unwrap()[0]
20589            .content
20590            .as_ref()
20591            .unwrap();
20592        assert_eq!(p2[0].text.as_deref(), Some("Second item"));
20593    }
20594
20595    #[test]
20596    fn issue_472_ordered_list_trailing_hardbreak_roundtrips() {
20597        // Ordered list variant of issue #472.
20598        let adf_json = r#"{"version":1,"type":"doc","content":[
20599          {"type":"orderedList","attrs":{"order":1},"content":[
20600            {"type":"listItem","content":[
20601              {"type":"paragraph","content":[
20602                {"type":"text","text":"Alpha"},
20603                {"type":"hardBreak"}
20604              ]}
20605            ]},
20606            {"type":"listItem","content":[
20607              {"type":"paragraph","content":[
20608                {"type":"text","text":"Beta"}
20609              ]}
20610            ]}
20611          ]}
20612        ]}"#;
20613        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20614        let md = adf_to_markdown(&doc).unwrap();
20615        let rt = markdown_to_adf(&md).unwrap();
20616
20617        assert_eq!(rt.content.len(), 1);
20618        assert_eq!(rt.content[0].node_type, "orderedList");
20619        let items = rt.content[0].content.as_ref().unwrap();
20620        assert_eq!(items.len(), 2);
20621
20622        let p1 = items[0].content.as_ref().unwrap()[0]
20623            .content
20624            .as_ref()
20625            .unwrap();
20626        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
20627        assert_eq!(types1, vec!["text", "hardBreak"]);
20628        assert_eq!(p1[0].text.as_deref(), Some("Alpha"));
20629    }
20630
20631    #[test]
20632    fn issue_472_trailing_hardbreak_jfm_no_blank_line() {
20633        // The rendered JFM must not contain a blank line after the
20634        // trailing hardBreak — that would split the list.
20635        let adf_json = r#"{"version":1,"type":"doc","content":[
20636          {"type":"bulletList","content":[
20637            {"type":"listItem","content":[
20638              {"type":"paragraph","content":[
20639                {"type":"text","text":"Hello"},
20640                {"type":"hardBreak"}
20641              ]}
20642            ]},
20643            {"type":"listItem","content":[
20644              {"type":"paragraph","content":[
20645                {"type":"text","text":"World"}
20646              ]}
20647            ]}
20648          ]}
20649        ]}"#;
20650        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20651        let md = adf_to_markdown(&doc).unwrap();
20652
20653        // Should produce "- Hello\\n- World\n" (no blank line between items).
20654        assert_eq!(md, "- Hello\\\n- World\n");
20655    }
20656
20657    #[test]
20658    fn issue_472_multiple_trailing_hardbreaks_roundtrip() {
20659        // Multiple trailing hardBreaks at the end of a listItem paragraph.
20660        let adf_json = r#"{"version":1,"type":"doc","content":[
20661          {"type":"bulletList","content":[
20662            {"type":"listItem","content":[
20663              {"type":"paragraph","content":[
20664                {"type":"text","text":"Item"},
20665                {"type":"hardBreak"},
20666                {"type":"hardBreak"}
20667              ]}
20668            ]},
20669            {"type":"listItem","content":[
20670              {"type":"paragraph","content":[
20671                {"type":"text","text":"Next"}
20672              ]}
20673            ]}
20674          ]}
20675        ]}"#;
20676        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20677        let md = adf_to_markdown(&doc).unwrap();
20678        let rt = markdown_to_adf(&md).unwrap();
20679
20680        // Must remain a single bulletList
20681        assert_eq!(rt.content.len(), 1);
20682        assert_eq!(rt.content[0].node_type, "bulletList");
20683        let items = rt.content[0].content.as_ref().unwrap();
20684        assert_eq!(items.len(), 2);
20685
20686        // First item should preserve both hardBreaks
20687        let p1 = items[0].content.as_ref().unwrap()[0]
20688            .content
20689            .as_ref()
20690            .unwrap();
20691        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
20692        assert_eq!(types1, vec!["text", "hardBreak", "hardBreak"]);
20693    }
20694
20695    #[test]
20696    fn issue_472_hardbreak_mid_and_trailing_roundtrip() {
20697        // A hardBreak in the middle AND at the end of a listItem paragraph.
20698        let adf_json = r#"{"version":1,"type":"doc","content":[
20699          {"type":"bulletList","content":[
20700            {"type":"listItem","content":[
20701              {"type":"paragraph","content":[
20702                {"type":"text","text":"Line one"},
20703                {"type":"hardBreak"},
20704                {"type":"text","text":"Line two"},
20705                {"type":"hardBreak"}
20706              ]}
20707            ]},
20708            {"type":"listItem","content":[
20709              {"type":"paragraph","content":[
20710                {"type":"text","text":"Other item"}
20711              ]}
20712            ]}
20713          ]}
20714        ]}"#;
20715        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20716        let md = adf_to_markdown(&doc).unwrap();
20717        let rt = markdown_to_adf(&md).unwrap();
20718
20719        assert_eq!(rt.content.len(), 1);
20720        assert_eq!(rt.content[0].node_type, "bulletList");
20721        let items = rt.content[0].content.as_ref().unwrap();
20722        assert_eq!(items.len(), 2);
20723
20724        let p1 = items[0].content.as_ref().unwrap()[0]
20725            .content
20726            .as_ref()
20727            .unwrap();
20728        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
20729        assert_eq!(types1, vec!["text", "hardBreak", "text", "hardBreak"]);
20730        assert_eq!(p1[0].text.as_deref(), Some("Line one"));
20731        assert_eq!(p1[2].text.as_deref(), Some("Line two"));
20732    }
20733
20734    #[test]
20735    fn issue_472_only_hardbreak_in_listitem_paragraph() {
20736        // Edge case: paragraph contains only a hardBreak, no text.
20737        let adf_json = r#"{"version":1,"type":"doc","content":[
20738          {"type":"bulletList","content":[
20739            {"type":"listItem","content":[
20740              {"type":"paragraph","content":[
20741                {"type":"hardBreak"}
20742              ]}
20743            ]},
20744            {"type":"listItem","content":[
20745              {"type":"paragraph","content":[
20746                {"type":"text","text":"After"}
20747              ]}
20748            ]}
20749          ]}
20750        ]}"#;
20751        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20752        let md = adf_to_markdown(&doc).unwrap();
20753        let rt = markdown_to_adf(&md).unwrap();
20754
20755        // Must remain a single bulletList with 2 items
20756        assert_eq!(rt.content.len(), 1);
20757        assert_eq!(rt.content[0].node_type, "bulletList");
20758        let items = rt.content[0].content.as_ref().unwrap();
20759        assert_eq!(items.len(), 2);
20760    }
20761
20762    #[test]
20763    fn issue_472_three_items_middle_has_trailing_hardbreak() {
20764        // Three-item list where only the middle item has a trailing hardBreak.
20765        let adf_json = r#"{"version":1,"type":"doc","content":[
20766          {"type":"bulletList","content":[
20767            {"type":"listItem","content":[
20768              {"type":"paragraph","content":[
20769                {"type":"text","text":"First"}
20770              ]}
20771            ]},
20772            {"type":"listItem","content":[
20773              {"type":"paragraph","content":[
20774                {"type":"text","text":"Second"},
20775                {"type":"hardBreak"}
20776              ]}
20777            ]},
20778            {"type":"listItem","content":[
20779              {"type":"paragraph","content":[
20780                {"type":"text","text":"Third"}
20781              ]}
20782            ]}
20783          ]}
20784        ]}"#;
20785        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20786        let md = adf_to_markdown(&doc).unwrap();
20787        let rt = markdown_to_adf(&md).unwrap();
20788
20789        assert_eq!(rt.content.len(), 1);
20790        assert_eq!(rt.content[0].node_type, "bulletList");
20791        let items = rt.content[0].content.as_ref().unwrap();
20792        assert_eq!(items.len(), 3);
20793        assert_eq!(
20794            items[0].content.as_ref().unwrap()[0]
20795                .content
20796                .as_ref()
20797                .unwrap()[0]
20798                .text
20799                .as_deref(),
20800            Some("First")
20801        );
20802        assert_eq!(
20803            items[2].content.as_ref().unwrap()[0]
20804                .content
20805                .as_ref()
20806                .unwrap()[0]
20807                .text
20808                .as_deref(),
20809            Some("Third")
20810        );
20811    }
20812
20813    // ── Issue #494: trailing space-only text node after hardBreak ────
20814
20815    #[test]
20816    fn issue_494_space_after_hardbreak_roundtrip() {
20817        // The original reproducer from issue #494: a single space text
20818        // node following a hardBreak is silently dropped on round-trip.
20819        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
20820          {"type":"text","text":"Some text"},
20821          {"type":"hardBreak"},
20822          {"type":"text","text":" "}
20823        ]}]}"#;
20824        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20825        let md = adf_to_markdown(&doc).unwrap();
20826        let rt = markdown_to_adf(&md).unwrap();
20827        let inlines = rt.content[0].content.as_ref().unwrap();
20828        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20829        assert_eq!(
20830            types,
20831            vec!["text", "hardBreak", "text"],
20832            "space-only text node after hardBreak should survive round-trip"
20833        );
20834        assert_eq!(inlines[2].text.as_deref(), Some(" "));
20835    }
20836
20837    #[test]
20838    fn issue_494_multiple_spaces_after_hardbreak_roundtrip() {
20839        // Multiple spaces after hardBreak should also survive.
20840        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
20841          {"type":"text","text":"Hello"},
20842          {"type":"hardBreak"},
20843          {"type":"text","text":"   "}
20844        ]}]}"#;
20845        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20846        let md = adf_to_markdown(&doc).unwrap();
20847        let rt = markdown_to_adf(&md).unwrap();
20848        let inlines = rt.content[0].content.as_ref().unwrap();
20849        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20850        assert_eq!(
20851            types,
20852            vec!["text", "hardBreak", "text"],
20853            "multi-space text node after hardBreak should survive round-trip"
20854        );
20855        assert_eq!(inlines[2].text.as_deref(), Some("   "));
20856    }
20857
20858    #[test]
20859    fn issue_494_space_then_text_after_hardbreak_roundtrip() {
20860        // Space followed by real text after hardBreak — the space should
20861        // be preserved as part of the text node.
20862        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
20863          {"type":"text","text":"Before"},
20864          {"type":"hardBreak"},
20865          {"type":"text","text":" After"}
20866        ]}]}"#;
20867        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20868        let md = adf_to_markdown(&doc).unwrap();
20869        let rt = markdown_to_adf(&md).unwrap();
20870        let inlines = rt.content[0].content.as_ref().unwrap();
20871        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20872        assert_eq!(types, vec!["text", "hardBreak", "text"]);
20873        assert_eq!(inlines[2].text.as_deref(), Some(" After"));
20874    }
20875
20876    #[test]
20877    fn issue_494_hardbreak_then_space_then_hardbreak_roundtrip() {
20878        // Space sandwiched between two hardBreaks.
20879        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
20880          {"type":"text","text":"A"},
20881          {"type":"hardBreak"},
20882          {"type":"text","text":" "},
20883          {"type":"hardBreak"},
20884          {"type":"text","text":"B"}
20885        ]}]}"#;
20886        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20887        let md = adf_to_markdown(&doc).unwrap();
20888        let rt = markdown_to_adf(&md).unwrap();
20889        let inlines = rt.content[0].content.as_ref().unwrap();
20890        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20891        assert_eq!(
20892            types,
20893            vec!["text", "hardBreak", "text", "hardBreak", "text"],
20894            "space between two hardBreaks should survive round-trip"
20895        );
20896        assert_eq!(inlines[2].text.as_deref(), Some(" "));
20897        assert_eq!(inlines[4].text.as_deref(), Some("B"));
20898    }
20899
20900    #[test]
20901    fn issue_494_trailing_space_hardbreak_style_not_confused() {
20902        // A plain paragraph break (blank line) should still work after
20903        // a line that does NOT end with a hardBreak marker.
20904        let md = "first paragraph\n\nsecond paragraph\n";
20905        let doc = markdown_to_adf(md).unwrap();
20906        assert_eq!(
20907            doc.content.len(),
20908            2,
20909            "blank line should still separate paragraphs"
20910        );
20911    }
20912
20913    #[test]
20914    fn issue_494_space_after_trailing_space_hardbreak_roundtrip() {
20915        // Same bug but with trailing-space style hardBreak (two spaces
20916        // before newline) instead of backslash style.
20917        let md = "line one  \n   \n";
20918        // The above is: "line one" + trailing-space hardBreak + continuation
20919        // line "   " (2-space indent + 1 space content).  The space-only
20920        // continuation should not be treated as a blank paragraph break.
20921        let doc = markdown_to_adf(md).unwrap();
20922        let inlines = doc.content[0].content.as_ref().unwrap();
20923        let has_text_after_break = inlines.iter().any(|n| {
20924            n.node_type == "text"
20925                && n.text
20926                    .as_deref()
20927                    .is_some_and(|t| t.trim().is_empty() && !t.is_empty())
20928        });
20929        assert!(
20930            has_text_after_break || inlines.len() >= 2,
20931            "space-only line after trailing-space hardBreak should be preserved"
20932        );
20933    }
20934
20935    #[test]
20936    fn issue_494_space_after_hardbreak_in_list_item_roundtrip() {
20937        // Exercises the same bug inside a list item context.
20938        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20939          {"type":"listItem","content":[{"type":"paragraph","content":[
20940            {"type":"text","text":"item"},
20941            {"type":"hardBreak"},
20942            {"type":"text","text":" "}
20943          ]}]}
20944        ]}]}"#;
20945        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20946        let md = adf_to_markdown(&doc).unwrap();
20947        let rt = markdown_to_adf(&md).unwrap();
20948        let list = &rt.content[0];
20949        let item = &list.content.as_ref().unwrap()[0];
20950        let para = &item.content.as_ref().unwrap()[0];
20951        let inlines = para.content.as_ref().unwrap();
20952        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20953        assert_eq!(
20954            types,
20955            vec!["text", "hardBreak", "text"],
20956            "space after hardBreak in list item should survive round-trip"
20957        );
20958        assert_eq!(inlines[2].text.as_deref(), Some(" "));
20959    }
20960
20961    // ── Issue #510: trailing spaces in text node should not become hardBreak ──
20962
20963    #[test]
20964    fn issue_510_trailing_double_space_paragraph_roundtrip() {
20965        // Two trailing spaces in a text node must survive round-trip without
20966        // being converted to a hardBreak or merging the next paragraph.
20967        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"}]}]}"#;
20968        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20969        let md = adf_to_markdown(&doc).unwrap();
20970        let rt = markdown_to_adf(&md).unwrap();
20971
20972        // Must produce two separate paragraphs
20973        assert_eq!(
20974            rt.content.len(),
20975            2,
20976            "should produce two paragraphs, got: {}",
20977            rt.content.len()
20978        );
20979        assert_eq!(rt.content[0].node_type, "paragraph");
20980        assert_eq!(rt.content[1].node_type, "paragraph");
20981
20982        // First paragraph text preserves trailing spaces
20983        let p1 = rt.content[0].content.as_ref().unwrap();
20984        assert_eq!(
20985            p1[0].text.as_deref(),
20986            Some("first paragraph with trailing spaces  "),
20987            "trailing spaces should be preserved in first paragraph"
20988        );
20989
20990        // Second paragraph is intact
20991        let p2 = rt.content[1].content.as_ref().unwrap();
20992        assert_eq!(p2[0].text.as_deref(), Some("second paragraph"));
20993
20994        // No hardBreak nodes should exist
20995        let all_types: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
20996        assert!(
20997            !all_types.contains(&"hardBreak"),
20998            "trailing spaces should not produce hardBreak, got: {all_types:?}"
20999        );
21000    }
21001
21002    #[test]
21003    fn issue_510_trailing_triple_space_roundtrip() {
21004        // Three trailing spaces also must not become a hardBreak.
21005        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"text   "}]},{"type":"paragraph","content":[{"type":"text","text":"next"}]}]}"#;
21006        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21007        let md = adf_to_markdown(&doc).unwrap();
21008        let rt = markdown_to_adf(&md).unwrap();
21009
21010        assert_eq!(rt.content.len(), 2, "should still be two paragraphs");
21011        let p1 = rt.content[0].content.as_ref().unwrap();
21012        assert_eq!(
21013            p1[0].text.as_deref(),
21014            Some("text   "),
21015            "three trailing spaces should be preserved"
21016        );
21017    }
21018
21019    #[test]
21020    fn issue_510_trailing_spaces_with_backslash_roundtrip() {
21021        // Text ending with backslash + trailing spaces: both must survive.
21022        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"end\\  "}]}]}"#;
21023        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21024        let md = adf_to_markdown(&doc).unwrap();
21025        let rt = markdown_to_adf(&md).unwrap();
21026        let p = rt.content[0].content.as_ref().unwrap();
21027        assert_eq!(
21028            p[0].text.as_deref(),
21029            Some("end\\  "),
21030            "backslash + trailing spaces should both survive"
21031        );
21032    }
21033
21034    #[test]
21035    fn issue_510_jfm_contains_escaped_trailing_space() {
21036        // Verify the serializer actually emits the backslash-space escape.
21037        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello  "}]}]}"#;
21038        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21039        let md = adf_to_markdown(&doc).unwrap();
21040        assert!(
21041            md.contains(r"\ "),
21042            "JFM should contain backslash-space escape for trailing spaces, got: {md:?}"
21043        );
21044        // Must NOT end with two plain spaces before newline
21045        for line in md.lines() {
21046            assert!(
21047                !line.ends_with("  "),
21048                "no JFM line should end with two plain spaces, got: {line:?}"
21049            );
21050        }
21051    }
21052
21053    #[test]
21054    fn issue_510_single_trailing_space_not_escaped() {
21055        // A single trailing space should NOT be escaped (not a hardBreak trigger).
21056        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"word "}]}]}"#;
21057        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21058        let md = adf_to_markdown(&doc).unwrap();
21059        assert!(
21060            !md.contains('\\'),
21061            "single trailing space should not be escaped, got: {md:?}"
21062        );
21063        let rt = markdown_to_adf(&md).unwrap();
21064        let p = rt.content[0].content.as_ref().unwrap();
21065        assert_eq!(p[0].text.as_deref(), Some("word "));
21066    }
21067
21068    #[test]
21069    fn issue_510_trailing_spaces_in_heading_roundtrip() {
21070        // Trailing double-spaces in a heading text node should also survive.
21071        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"heading  "}]}]}"#;
21072        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21073        let md = adf_to_markdown(&doc).unwrap();
21074        let rt = markdown_to_adf(&md).unwrap();
21075        let h = rt.content[0].content.as_ref().unwrap();
21076        assert_eq!(
21077            h[0].text.as_deref(),
21078            Some("heading  "),
21079            "trailing spaces in heading should be preserved"
21080        );
21081    }
21082
21083    #[test]
21084    fn issue_510_trailing_spaces_in_list_item_roundtrip() {
21085        // Trailing double-spaces in a bullet list item text node.
21086        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"item  "}]}]}]}]}"#;
21087        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21088        let md = adf_to_markdown(&doc).unwrap();
21089        let rt = markdown_to_adf(&md).unwrap();
21090        let list = &rt.content[0];
21091        let item = &list.content.as_ref().unwrap()[0];
21092        let para = &item.content.as_ref().unwrap()[0];
21093        let inlines = para.content.as_ref().unwrap();
21094        assert_eq!(
21095            inlines[0].text.as_deref(),
21096            Some("item  "),
21097            "trailing spaces in list item should be preserved"
21098        );
21099    }
21100
21101    #[test]
21102    fn issue_510_trailing_spaces_with_bold_mark_roundtrip() {
21103        // Trailing spaces in a bold-marked text node: the closing **
21104        // comes after the spaces, so the line doesn't end with spaces.
21105        // But the escape should still be applied (and be harmless).
21106        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"bold  ","marks":[{"type":"strong"}]}]}]}"#;
21107        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21108        let md = adf_to_markdown(&doc).unwrap();
21109        let rt = markdown_to_adf(&md).unwrap();
21110        let p = rt.content[0].content.as_ref().unwrap();
21111        assert_eq!(
21112            p[0].text.as_deref(),
21113            Some("bold  "),
21114            "trailing spaces in bold text should be preserved"
21115        );
21116    }
21117
21118    #[test]
21119    fn issue_510_hardbreak_between_paragraphs_still_works() {
21120        // Actual hardBreak nodes must still round-trip correctly.
21121        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"line one"},{"type":"hardBreak"},{"type":"text","text":"line two"}]}]}"#;
21122        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21123        let md = adf_to_markdown(&doc).unwrap();
21124        let rt = markdown_to_adf(&md).unwrap();
21125        let inlines = rt.content[0].content.as_ref().unwrap();
21126        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21127        assert_eq!(
21128            types,
21129            vec!["text", "hardBreak", "text"],
21130            "explicit hardBreak should still round-trip"
21131        );
21132    }
21133
21134    #[test]
21135    fn issue_510_all_spaces_text_node_roundtrip() {
21136        // A text node that is entirely spaces (2+) should survive.
21137        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"  "}]}]}"#;
21138        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21139        let md = adf_to_markdown(&doc).unwrap();
21140        let rt = markdown_to_adf(&md).unwrap();
21141        let p = rt.content[0].content.as_ref().unwrap();
21142        assert_eq!(
21143            p[0].text.as_deref(),
21144            Some("  "),
21145            "space-only text node should survive round-trip"
21146        );
21147    }
21148
21149    // ── Issue #522: listItem multi-paragraph merge ──────────────────────
21150
21151    #[test]
21152    fn issue_522_listitem_hardbreak_then_two_paragraphs_roundtrips() {
21153        // The exact reproducer from issue #522: first paragraph has
21154        // hardBreak nodes, followed by two sibling paragraphs.
21155        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"}]}]}]}]}"#;
21156        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21157        let md = adf_to_markdown(&doc).unwrap();
21158        let rt = markdown_to_adf(&md).unwrap();
21159
21160        let items = rt.content[0].content.as_ref().unwrap();
21161        assert_eq!(items.len(), 1);
21162        let children = items[0].content.as_ref().unwrap();
21163        assert_eq!(
21164            children.len(),
21165            3,
21166            "Expected 3 paragraphs in listItem, got {}",
21167            children.len()
21168        );
21169        assert_eq!(children[0].node_type, "paragraph");
21170        assert_eq!(children[1].node_type, "paragraph");
21171        assert_eq!(children[2].node_type, "paragraph");
21172
21173        // Verify the text content of each paragraph
21174        let text1 = children[1].content.as_ref().unwrap()[0]
21175            .text
21176            .as_deref()
21177            .unwrap();
21178        assert_eq!(text1, "second paragraph");
21179        let text2 = children[2].content.as_ref().unwrap()[0]
21180            .text
21181            .as_deref()
21182            .unwrap();
21183        assert_eq!(text2, "third paragraph");
21184    }
21185
21186    #[test]
21187    fn issue_522_ordered_list_hardbreak_then_paragraphs_roundtrips() {
21188        // Same scenario in an ordered list.
21189        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"}]}]}]}]}"#;
21190        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21191        let md = adf_to_markdown(&doc).unwrap();
21192        let rt = markdown_to_adf(&md).unwrap();
21193
21194        let items = rt.content[0].content.as_ref().unwrap();
21195        let children = items[0].content.as_ref().unwrap();
21196        assert_eq!(
21197            children.len(),
21198            3,
21199            "Expected 3 paragraphs in ordered listItem, got {}",
21200            children.len()
21201        );
21202        assert_eq!(children[1].node_type, "paragraph");
21203        assert_eq!(children[2].node_type, "paragraph");
21204        assert_eq!(
21205            children[1].content.as_ref().unwrap()[0]
21206                .text
21207                .as_deref()
21208                .unwrap(),
21209            "second para"
21210        );
21211        assert_eq!(
21212            children[2].content.as_ref().unwrap()[0]
21213                .text
21214                .as_deref()
21215                .unwrap(),
21216            "third para"
21217        );
21218    }
21219
21220    #[test]
21221    fn issue_522_two_paragraphs_without_hardbreak_roundtrips() {
21222        // Two paragraphs without hardBreak — should also remain separate.
21223        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"}]}]}]}]}"#;
21224        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21225        let md = adf_to_markdown(&doc).unwrap();
21226        let rt = markdown_to_adf(&md).unwrap();
21227
21228        let items = rt.content[0].content.as_ref().unwrap();
21229        let children = items[0].content.as_ref().unwrap();
21230        assert_eq!(
21231            children.len(),
21232            2,
21233            "Expected 2 paragraphs in listItem, got {}",
21234            children.len()
21235        );
21236        assert_eq!(children[0].node_type, "paragraph");
21237        assert_eq!(children[1].node_type, "paragraph");
21238    }
21239
21240    #[test]
21241    fn issue_522_paragraph_then_nested_list_no_spurious_blank() {
21242        // A paragraph followed by a nested list should NOT get a blank
21243        // separator (only paragraph-paragraph transitions need one).
21244        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"}]}]}]}]}]}]}"#;
21245        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21246        let md = adf_to_markdown(&doc).unwrap();
21247        // Should not contain a blank indented line between parent text and sub-list
21248        assert!(
21249            !md.contains("  \n  -"),
21250            "No blank separator between paragraph and nested list"
21251        );
21252        let rt = markdown_to_adf(&md).unwrap();
21253
21254        let items = rt.content[0].content.as_ref().unwrap();
21255        let children = items[0].content.as_ref().unwrap();
21256        assert_eq!(children.len(), 2);
21257        assert_eq!(children[0].node_type, "paragraph");
21258        assert_eq!(children[1].node_type, "bulletList");
21259    }
21260
21261    #[test]
21262    fn issue_522_three_paragraphs_no_hardbreak_roundtrips() {
21263        // Three plain paragraphs (no hardBreak) inside a single listItem.
21264        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"}]}]}]}]}"#;
21265        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21266        let md = adf_to_markdown(&doc).unwrap();
21267        let rt = markdown_to_adf(&md).unwrap();
21268
21269        let items = rt.content[0].content.as_ref().unwrap();
21270        let children = items[0].content.as_ref().unwrap();
21271        assert_eq!(
21272            children.len(),
21273            3,
21274            "Expected 3 paragraphs, got {}",
21275            children.len()
21276        );
21277        for (i, child) in children.iter().enumerate() {
21278            assert_eq!(
21279                child.node_type, "paragraph",
21280                "Child {i} should be a paragraph"
21281            );
21282        }
21283    }
21284
21285    #[test]
21286    fn issue_522_multiple_list_items_each_with_paragraphs() {
21287        // Multiple list items, each with multiple paragraphs.
21288        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"}]}]}]}]}"#;
21289        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21290        let md = adf_to_markdown(&doc).unwrap();
21291        let rt = markdown_to_adf(&md).unwrap();
21292
21293        let items = rt.content[0].content.as_ref().unwrap();
21294        assert_eq!(items.len(), 2, "Expected 2 list items");
21295
21296        let item1 = items[0].content.as_ref().unwrap();
21297        assert_eq!(item1.len(), 2, "Item 1 should have 2 paragraphs");
21298
21299        let item2 = items[1].content.as_ref().unwrap();
21300        assert_eq!(item2.len(), 2, "Item 2 should have 2 paragraphs");
21301        // Verify hardBreak is preserved in item2's first paragraph
21302        let item2_p1_inlines = item2[0].content.as_ref().unwrap();
21303        let types: Vec<&str> = item2_p1_inlines
21304            .iter()
21305            .map(|n| n.node_type.as_str())
21306            .collect();
21307        assert_eq!(types, vec!["text", "hardBreak", "text"]);
21308    }
21309
21310    #[test]
21311    fn issue_531_blockquote_hardbreak_then_two_paragraphs_roundtrips() {
21312        // The exact reproducer from issue #531: blockquote with first
21313        // paragraph containing hardBreak nodes, followed by two sibling
21314        // paragraphs.
21315        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"}]}]}]}"#;
21316        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21317        let md = adf_to_markdown(&doc).unwrap();
21318        let rt = markdown_to_adf(&md).unwrap();
21319
21320        let children = rt.content[0].content.as_ref().unwrap();
21321        assert_eq!(
21322            children.len(),
21323            3,
21324            "Expected 3 paragraphs in blockquote, got {}",
21325            children.len()
21326        );
21327        assert_eq!(children[0].node_type, "paragraph");
21328        assert_eq!(children[1].node_type, "paragraph");
21329        assert_eq!(children[2].node_type, "paragraph");
21330
21331        let text1 = children[1].content.as_ref().unwrap()[0]
21332            .text
21333            .as_deref()
21334            .unwrap();
21335        assert_eq!(text1, "second paragraph");
21336        let text2 = children[2].content.as_ref().unwrap()[0]
21337            .text
21338            .as_deref()
21339            .unwrap();
21340        assert_eq!(text2, "third paragraph");
21341    }
21342
21343    #[test]
21344    fn issue_531_blockquote_two_paragraphs_without_hardbreak_roundtrips() {
21345        // Two simple paragraphs inside a blockquote, no hardBreak.
21346        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"}]}]}]}"#;
21347        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21348        let md = adf_to_markdown(&doc).unwrap();
21349        let rt = markdown_to_adf(&md).unwrap();
21350
21351        let children = rt.content[0].content.as_ref().unwrap();
21352        assert_eq!(
21353            children.len(),
21354            2,
21355            "Expected 2 paragraphs in blockquote, got {}",
21356            children.len()
21357        );
21358        assert_eq!(children[0].node_type, "paragraph");
21359        assert_eq!(children[1].node_type, "paragraph");
21360    }
21361
21362    #[test]
21363    fn issue_531_blockquote_three_paragraphs_no_hardbreak_roundtrips() {
21364        // Three paragraphs, none with hardBreak.
21365        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"}]}]}]}"#;
21366        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21367        let md = adf_to_markdown(&doc).unwrap();
21368        let rt = markdown_to_adf(&md).unwrap();
21369
21370        let children = rt.content[0].content.as_ref().unwrap();
21371        assert_eq!(
21372            children.len(),
21373            3,
21374            "Expected 3 paragraphs in blockquote, got {}",
21375            children.len()
21376        );
21377        for child in children {
21378            assert_eq!(child.node_type, "paragraph");
21379        }
21380    }
21381
21382    #[test]
21383    fn issue_531_blockquote_paragraph_then_list_no_spurious_blank() {
21384        // A paragraph followed by a nested list inside a blockquote —
21385        // should NOT insert a blank separator line.
21386        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"}]}]}]}]}]}"#;
21387        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21388        let md = adf_to_markdown(&doc).unwrap();
21389        let rt = markdown_to_adf(&md).unwrap();
21390
21391        let children = rt.content[0].content.as_ref().unwrap();
21392        assert_eq!(children[0].node_type, "paragraph");
21393        assert_eq!(children[1].node_type, "bulletList");
21394    }
21395
21396    #[test]
21397    fn issue_531_blockquote_single_paragraph_unchanged() {
21398        // A single paragraph in a blockquote should remain unchanged.
21399        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"solo"}]}]}]}"#;
21400        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21401        let md = adf_to_markdown(&doc).unwrap();
21402        let rt = markdown_to_adf(&md).unwrap();
21403
21404        let children = rt.content[0].content.as_ref().unwrap();
21405        assert_eq!(children.len(), 1);
21406        assert_eq!(children[0].node_type, "paragraph");
21407        let text = children[0].content.as_ref().unwrap()[0]
21408            .text
21409            .as_deref()
21410            .unwrap();
21411        assert_eq!(text, "solo");
21412    }
21413
21414    // ── Issue #554: marks combined with `code` or with each other ──────
21415
21416    /// Helper: roundtrip an ADF document and assert the marks on the first
21417    /// text node match `expected_marks` (in order).
21418    fn assert_roundtrip_marks(adf_json: &str, expected_marks: &[&str]) {
21419        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21420        let md = adf_to_markdown(&doc).unwrap();
21421        let rt = markdown_to_adf(&md).unwrap();
21422        let node = &rt.content[0].content.as_ref().unwrap()[0];
21423        let mark_types: Vec<&str> = node
21424            .marks
21425            .as_ref()
21426            .expect("should have marks")
21427            .iter()
21428            .map(|m| m.mark_type.as_str())
21429            .collect();
21430        assert_eq!(
21431            mark_types, expected_marks,
21432            "mark order mismatch for md={md}"
21433        );
21434    }
21435
21436    #[test]
21437    fn issue_554_code_and_text_color_preserved() {
21438        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21439          {"type":"text","text":"x","marks":[
21440            {"type":"textColor","attrs":{"color":"#008000"}},
21441            {"type":"code"}
21442          ]}
21443        ]}]}"##;
21444        assert_roundtrip_marks(adf_json, &["textColor", "code"]);
21445    }
21446
21447    #[test]
21448    fn issue_554_code_and_bg_color_preserved() {
21449        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21450          {"type":"text","text":"x","marks":[
21451            {"type":"backgroundColor","attrs":{"color":"#FF0000"}},
21452            {"type":"code"}
21453          ]}
21454        ]}]}"##;
21455        assert_roundtrip_marks(adf_json, &["backgroundColor", "code"]);
21456    }
21457
21458    #[test]
21459    fn issue_554_code_and_subsup_preserved() {
21460        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21461          {"type":"text","text":"x","marks":[
21462            {"type":"subsup","attrs":{"type":"sub"}},
21463            {"type":"code"}
21464          ]}
21465        ]}]}"#;
21466        assert_roundtrip_marks(adf_json, &["subsup", "code"]);
21467    }
21468
21469    #[test]
21470    fn issue_554_code_and_underline_preserved() {
21471        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21472          {"type":"text","text":"x","marks":[
21473            {"type":"underline"},
21474            {"type":"code"}
21475          ]}
21476        ]}]}"#;
21477        assert_roundtrip_marks(adf_json, &["underline", "code"]);
21478    }
21479
21480    #[test]
21481    fn issue_554_code_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            {"type":"code"}
21487          ]}
21488        ]}]}"##;
21489        assert_roundtrip_marks(adf_json, &["textColor", "underline", "code"]);
21490    }
21491
21492    #[test]
21493    fn issue_554_textcolor_and_underline_preserved() {
21494        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21495          {"type":"text","text":"x","marks":[
21496            {"type":"textColor","attrs":{"color":"#008000"}},
21497            {"type":"underline"}
21498          ]}
21499        ]}]}"##;
21500        assert_roundtrip_marks(adf_json, &["textColor", "underline"]);
21501    }
21502
21503    #[test]
21504    fn issue_554_underline_and_textcolor_preserved_order_swapped() {
21505        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21506          {"type":"text","text":"x","marks":[
21507            {"type":"underline"},
21508            {"type":"textColor","attrs":{"color":"#008000"}}
21509          ]}
21510        ]}]}"##;
21511        // underline appears first, so it should be the OUTER wrapper.
21512        assert_roundtrip_marks(adf_json, &["underline", "textColor"]);
21513    }
21514
21515    #[test]
21516    fn issue_554_textcolor_and_annotation_preserved() {
21517        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21518          {"type":"text","text":"x","marks":[
21519            {"type":"textColor","attrs":{"color":"#008000"}},
21520            {"type":"annotation","attrs":{"id":"abc-123","annotationType":"inlineComment"}}
21521          ]}
21522        ]}]}"##;
21523        assert_roundtrip_marks(adf_json, &["textColor", "annotation"]);
21524    }
21525
21526    #[test]
21527    fn issue_554_bgcolor_and_underline_preserved() {
21528        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21529          {"type":"text","text":"x","marks":[
21530            {"type":"backgroundColor","attrs":{"color":"#FF0000"}},
21531            {"type":"underline"}
21532          ]}
21533        ]}]}"##;
21534        assert_roundtrip_marks(adf_json, &["backgroundColor", "underline"]);
21535    }
21536
21537    #[test]
21538    fn issue_554_subsup_and_underline_preserved() {
21539        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21540          {"type":"text","text":"x","marks":[
21541            {"type":"subsup","attrs":{"type":"sub"}},
21542            {"type":"underline"}
21543          ]}
21544        ]}]}"#;
21545        assert_roundtrip_marks(adf_json, &["subsup", "underline"]);
21546    }
21547
21548    #[test]
21549    fn issue_554_exact_reproducer_full_match() {
21550        // The exact reproducer from issue #554. The byte-for-byte ADF JSON
21551        // must round-trip through `from-adf | to-adf` unchanged.
21552        let adf_json = r##"{
21553          "version": 1,
21554          "type": "doc",
21555          "content": [
21556            {
21557              "type": "paragraph",
21558              "content": [
21559                {"type":"text","text":"Status: ","marks":[{"type":"strong"}]},
21560                {"type":"text","text":"Approved","marks":[
21561                  {"type":"textColor","attrs":{"color":"#008000"}}
21562                ]},
21563                {"type":"text","text":" — ready to proceed"}
21564              ]
21565            }
21566          ]
21567        }"##;
21568        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21569        let md = adf_to_markdown(&doc).unwrap();
21570        assert!(
21571            md.contains(":span[Approved]{color=#008000}"),
21572            "JFM should contain green span: {md}"
21573        );
21574        let rt = markdown_to_adf(&md).unwrap();
21575        // Find the "Approved" text node and verify color is preserved.
21576        let approved = rt.content[0]
21577            .content
21578            .as_ref()
21579            .unwrap()
21580            .iter()
21581            .find(|n| n.text.as_deref() == Some("Approved"))
21582            .expect("Approved text node");
21583        let marks = approved.marks.as_ref().expect("should have marks");
21584        let color_mark = marks
21585            .iter()
21586            .find(|m| m.mark_type == "textColor")
21587            .expect("textColor mark must be preserved");
21588        assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#008000");
21589    }
21590
21591    #[test]
21592    fn issue_554_textcolor_with_code_renders_span_around_code() {
21593        // Verify the rendered JFM uses `:span[`text`]{color=...}` — the
21594        // syntax suggested in the issue.
21595        let doc = AdfDocument {
21596            version: 1,
21597            doc_type: "doc".to_string(),
21598            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
21599                "fn main",
21600                vec![
21601                    AdfMark::text_color("#008000"),
21602                    AdfMark {
21603                        mark_type: "code".to_string(),
21604                        attrs: None,
21605                    },
21606                ],
21607            )])],
21608        };
21609        let md = adf_to_markdown(&doc).unwrap();
21610        assert!(
21611            md.contains(":span[`fn main`]{color=#008000}"),
21612            "expected span-wrapped code, got: {md}"
21613        );
21614    }
21615
21616    #[test]
21617    fn issue_554_underline_with_code_renders_bracketed_around_code() {
21618        let doc = AdfDocument {
21619            version: 1,
21620            doc_type: "doc".to_string(),
21621            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
21622                "fn main",
21623                vec![
21624                    AdfMark::underline(),
21625                    AdfMark {
21626                        mark_type: "code".to_string(),
21627                        attrs: None,
21628                    },
21629                ],
21630            )])],
21631        };
21632        let md = adf_to_markdown(&doc).unwrap();
21633        assert!(
21634            md.contains("[`fn main`]{underline}"),
21635            "expected bracketed-span around code, got: {md}"
21636        );
21637    }
21638
21639    // ── Issue #554 (re-opened): boundary-underscore destroys span directives ──
21640
21641    #[test]
21642    fn issue_554_underscore_adjacent_to_textcolor_span_roundtrip() {
21643        // Reproducer from the re-opened issue: a `_ ` plain-text node followed
21644        // by a textColor span whose text starts with `_` produced JFM that the
21645        // parser saw as an italic delimiter pair, destroying the span and
21646        // losing the textColor mark entirely.
21647        let adf_json = r##"{
21648          "version": 1,
21649          "type": "doc",
21650          "content": [
21651            {
21652              "type": "paragraph",
21653              "content": [
21654                {"type":"text","text":"_ "},
21655                {"type":"text","text":"_Action:*","marks":[
21656                  {"type":"textColor","attrs":{"color":"#008000"}}
21657                ]},
21658                {"type":"text","text":" Complete the setup process."}
21659              ]
21660            }
21661          ]
21662        }"##;
21663        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21664        let md = adf_to_markdown(&doc).unwrap();
21665        // The leading `_` chars must be backslash-escaped so the parser
21666        // doesn't form a false italic pair across the span boundary.
21667        assert!(
21668            md.contains(r"\_ ") && md.contains(r":span[\_Action"),
21669            "underscores at node boundaries should be escaped: {md}"
21670        );
21671        let rt = markdown_to_adf(&md).unwrap();
21672        let para_content = rt.content[0].content.as_ref().unwrap();
21673        // Find the textColor-marked node.
21674        let colored = para_content
21675            .iter()
21676            .find(|n| {
21677                n.marks
21678                    .as_deref()
21679                    .is_some_and(|ms| ms.iter().any(|m| m.mark_type == "textColor"))
21680            })
21681            .expect("textColor node must be preserved");
21682        assert_eq!(colored.text.as_deref(), Some("_Action:*"));
21683        let color_mark = colored
21684            .marks
21685            .as_ref()
21686            .unwrap()
21687            .iter()
21688            .find(|m| m.mark_type == "textColor")
21689            .unwrap();
21690        assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#008000");
21691        // Verify no spurious em mark crept in.
21692        for n in para_content {
21693            if let Some(ms) = n.marks.as_deref() {
21694                assert!(
21695                    !ms.iter().any(|m| m.mark_type == "em"),
21696                    "no em mark should appear, got marks {:?}",
21697                    ms.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
21698                );
21699            }
21700        }
21701    }
21702
21703    #[test]
21704    fn issue_554_underscore_intraword_left_unescaped() {
21705        // Sanity check: ordinary intraword underscores like `do_something_useful`
21706        // should NOT be escaped — escaping would still round-trip correctly,
21707        // but produces noisy backslashes in the JFM output.
21708        let doc = AdfDocument {
21709            version: 1,
21710            doc_type: "doc".to_string(),
21711            content: vec![AdfNode::paragraph(vec![AdfNode::text(
21712                "call do_something_useful now",
21713            )])],
21714        };
21715        let md = adf_to_markdown(&doc).unwrap();
21716        assert!(
21717            md.contains("do_something_useful") && !md.contains(r"do\_something\_useful"),
21718            "intraword underscores should not be escaped: {md}"
21719        );
21720    }
21721
21722    #[test]
21723    fn issue_554_code_underline_then_textcolor_bracketed_outer() {
21724        // Mark order [underline, textColor, code] — bracketed-span outer,
21725        // span inner. Exercises wrap_with_attrs (true, true) !span_before.
21726        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21727          {"type":"text","text":"x","marks":[
21728            {"type":"underline"},
21729            {"type":"textColor","attrs":{"color":"#008000"}},
21730            {"type":"code"}
21731          ]}
21732        ]}]}"##;
21733        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21734        let md = adf_to_markdown(&doc).unwrap();
21735        // Bracketed-span should be the outermost wrapper.
21736        assert!(
21737            md.starts_with('[') && md.contains("underline}"),
21738            "bracketed-span should wrap the span, got: {md}"
21739        );
21740        let rt = markdown_to_adf(&md).unwrap();
21741        let node = &rt.content[0].content.as_ref().unwrap()[0];
21742        let mark_types: Vec<&str> = node
21743            .marks
21744            .as_ref()
21745            .unwrap()
21746            .iter()
21747            .map(|m| m.mark_type.as_str())
21748            .collect();
21749        assert_eq!(mark_types, vec!["underline", "textColor", "code"]);
21750    }
21751
21752    #[test]
21753    fn issue_554_textcolor_underline_link_all_preserved() {
21754        // Mark order [textColor, underline, link] — span outer, bracketed
21755        // wraps the link inside. Exercises the span-wraps-link-with-bracketed
21756        // branch.
21757        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21758          {"type":"text","text":"linked","marks":[
21759            {"type":"textColor","attrs":{"color":"#008000"}},
21760            {"type":"underline"},
21761            {"type":"link","attrs":{"href":"https://example.com"}}
21762          ]}
21763        ]}]}"##;
21764        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21765        let md = adf_to_markdown(&doc).unwrap();
21766        let rt = markdown_to_adf(&md).unwrap();
21767        let node = &rt.content[0].content.as_ref().unwrap()[0];
21768        let mark_types: Vec<&str> = node
21769            .marks
21770            .as_ref()
21771            .unwrap()
21772            .iter()
21773            .map(|m| m.mark_type.as_str())
21774            .collect();
21775        assert_eq!(mark_types, vec!["textColor", "underline", "link"]);
21776    }
21777
21778    #[test]
21779    fn issue_554_underline_textcolor_link_bracketed_outer_link_last() {
21780        // Mark order [underline, textColor, link] — bracketed-span outer of
21781        // both span and link. Exercises the bracketed-wraps-everything branch.
21782        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21783          {"type":"text","text":"linked","marks":[
21784            {"type":"underline"},
21785            {"type":"textColor","attrs":{"color":"#008000"}},
21786            {"type":"link","attrs":{"href":"https://example.com"}}
21787          ]}
21788        ]}]}"##;
21789        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21790        let md = adf_to_markdown(&doc).unwrap();
21791        let rt = markdown_to_adf(&md).unwrap();
21792        let node = &rt.content[0].content.as_ref().unwrap()[0];
21793        let mark_types: Vec<&str> = node
21794            .marks
21795            .as_ref()
21796            .unwrap()
21797            .iter()
21798            .map(|m| m.mark_type.as_str())
21799            .collect();
21800        assert_eq!(mark_types, vec!["underline", "textColor", "link"]);
21801    }
21802
21803    #[test]
21804    fn issue_554_link_underline_textcolor_link_outer() {
21805        // Mark order [link, underline, textColor] — link outermost, wraps a
21806        // bracketed-span that wraps the span. Exercises the link-wraps-
21807        // bracketed-wraps-span branch.
21808        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21809          {"type":"text","text":"linked","marks":[
21810            {"type":"link","attrs":{"href":"https://example.com"}},
21811            {"type":"underline"},
21812            {"type":"textColor","attrs":{"color":"#008000"}}
21813          ]}
21814        ]}]}"##;
21815        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21816        let md = adf_to_markdown(&doc).unwrap();
21817        assert!(
21818            md.starts_with('[') && md.contains("](https://example.com)"),
21819            "link should be outermost, got: {md}"
21820        );
21821        let rt = markdown_to_adf(&md).unwrap();
21822        let node = &rt.content[0].content.as_ref().unwrap()[0];
21823        let mark_types: Vec<&str> = node
21824            .marks
21825            .as_ref()
21826            .unwrap()
21827            .iter()
21828            .map(|m| m.mark_type.as_str())
21829            .collect();
21830        assert_eq!(mark_types, vec!["link", "underline", "textColor"]);
21831    }
21832
21833    #[test]
21834    fn issue_554_trailing_underscore_then_leading_underscore_round_trip() {
21835        // Two adjacent text nodes where the first ends with `_` and the
21836        // second starts with `_` — without escaping, the JFM parser sees
21837        // an `_..._` pair spanning the boundary.
21838        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21839          {"type":"text","text":"end_"},
21840          {"type":"text","text":"_start"}
21841        ]}]}"#;
21842        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21843        let md = adf_to_markdown(&doc).unwrap();
21844        let rt = markdown_to_adf(&md).unwrap();
21845        // Reassemble all text in the paragraph.
21846        let combined: String = rt.content[0]
21847            .content
21848            .as_ref()
21849            .unwrap()
21850            .iter()
21851            .filter_map(|n| n.text.as_deref())
21852            .collect();
21853        assert_eq!(combined, "end__start");
21854        // No node should have an em mark.
21855        for n in rt.content[0].content.as_ref().unwrap() {
21856            if let Some(ms) = n.marks.as_deref() {
21857                assert!(!ms.iter().any(|m| m.mark_type == "em"));
21858            }
21859        }
21860    }
21861}