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/// Flattens an ADF document to plain text by concatenating all `text` nodes.
2798///
2799/// Block boundaries (paragraph, heading, list item, table cell, etc.) are
2800/// separated by a single space so a multi-paragraph anchor selection matches
2801/// when the user copies the text without trailing/leading whitespace.
2802///
2803/// Used by [`crate::atlassian::confluence_api::ConfluenceApi::resolve_anchor`]
2804/// to count occurrences of an inline-comment anchor on the live page body.
2805#[must_use]
2806pub fn adf_to_plain_text(doc: &AdfDocument) -> String {
2807    let mut out = String::new();
2808    for node in &doc.content {
2809        collect_plain_text(node, &mut out);
2810        if !out.is_empty() && !out.ends_with(' ') {
2811            out.push(' ');
2812        }
2813    }
2814    out.truncate(out.trim_end().len());
2815    out
2816}
2817
2818fn collect_plain_text(node: &AdfNode, out: &mut String) {
2819    if let Some(text) = &node.text {
2820        out.push_str(text);
2821    }
2822    if let Some(children) = &node.content {
2823        for child in children {
2824            collect_plain_text(child, out);
2825        }
2826    }
2827}
2828
2829/// Pushes a `localId=<value>` entry to an attribute parts vec,
2830/// unless `opts.strip_local_ids` is set or the value is a placeholder.
2831/// Copies `localId` from parsed directive attrs to an ADF node's attrs if present.
2832fn pass_through_local_id(dir_attrs: &Option<crate::atlassian::attrs::Attrs>, node: &mut AdfNode) {
2833    if let Some(ref attrs) = dir_attrs {
2834        if let Some(local_id) = attrs.get("localId") {
2835            if let Some(ref mut node_attrs) = node.attrs {
2836                node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
2837            } else {
2838                node.attrs = Some(serde_json::json!({"localId": local_id}));
2839            }
2840        }
2841    }
2842}
2843
2844/// Copies `localId` from directive attrs to the node's top-level `local_id` field,
2845/// and parses `params` JSON from directive attrs into the node's `parameters` field.
2846fn pass_through_expand_params(
2847    dir_attrs: &Option<crate::atlassian::attrs::Attrs>,
2848    node: &mut AdfNode,
2849) {
2850    if let Some(ref attrs) = dir_attrs {
2851        if let Some(local_id) = attrs.get("localId") {
2852            node.local_id = Some(local_id.to_string());
2853        }
2854        if let Some(params_str) = attrs.get("params") {
2855            if let Ok(params) = serde_json::from_str(params_str) {
2856                node.parameters = Some(params);
2857            }
2858        }
2859    }
2860}
2861
2862// listItem localId is emitted as trailing inline attrs on the item line
2863// (e.g., `- item text {localId=...}`) and parsed back by extracting
2864// trailing attrs from the list item text. This avoids the block-attrs
2865// promotion issue where {localId=...} on a separate line would be
2866// applied to the parent list node.
2867
2868/// Extracts trailing `{localId=... paraLocalId=...}` from list item text.
2869/// Returns the text without the trailing attrs, the listItem localId, and
2870/// the paragraph localId if found.
2871fn extract_trailing_local_id(text: &str) -> (&str, Option<String>, Option<String>) {
2872    let trimmed = text.trim_end();
2873    if !trimmed.ends_with('}') {
2874        return (text, None, None);
2875    }
2876    // Find the opening brace.  Only match a standalone `{…}` block that is
2877    // preceded by whitespace (or is at the start of the string).  A `{` that
2878    // immediately follows `]` is part of an inline directive (e.g.
2879    // `:mention[text]{id=… localId=…}`) and must NOT be consumed here.
2880    if let Some(brace_pos) = trimmed.rfind('{') {
2881        if brace_pos > 0 && !trimmed.as_bytes()[brace_pos - 1].is_ascii_whitespace() {
2882            return (text, None, None);
2883        }
2884        let attr_str = &trimmed[brace_pos..];
2885        if let Some((_, attrs)) = parse_attrs(attr_str, 0) {
2886            let local_id = attrs.get("localId").map(str::to_string);
2887            let para_local_id = attrs.get("paraLocalId").map(str::to_string);
2888            if local_id.is_some() || para_local_id.is_some() {
2889                let before = trimmed[..brace_pos]
2890                    .strip_suffix(' ')
2891                    .unwrap_or(&trimmed[..brace_pos]);
2892                return (before, local_id, para_local_id);
2893            }
2894        }
2895    }
2896    (text, None, None)
2897}
2898
2899/// Creates a `listItem` node, optionally with a `localId` attribute
2900/// and a `paraLocalId` on its first paragraph child.
2901/// Parses the first line of a list item and any indented sub-content into
2902/// an `AdfNode::list_item`.  When the first line is a code fence opener
2903/// (`` ``` ``), the line is folded into the sub-content so the block-level
2904/// code fence parser handles it correctly (issue #511).
2905fn parse_list_item_first_line(
2906    item_text: &str,
2907    sub_lines: Vec<String>,
2908    local_id: Option<String>,
2909    para_local_id: Option<String>,
2910) -> Result<AdfNode> {
2911    if item_text.starts_with("```") {
2912        // Treat the code fence opener + indented body as block content.
2913        let mut all_lines = vec![item_text.to_string()];
2914        all_lines.extend(sub_lines);
2915        let combined = all_lines.join("\n");
2916        let nested = MarkdownParser::new(&combined).parse_blocks()?;
2917        Ok(list_item_with_local_id(nested, local_id, para_local_id))
2918    } else if let Some(media) = try_parse_media_single_from_line(item_text) {
2919        // Block-level image (issue #430).
2920        if sub_lines.is_empty() {
2921            Ok(list_item_with_local_id(
2922                vec![media],
2923                local_id,
2924                para_local_id,
2925            ))
2926        } else {
2927            let sub_text = sub_lines.join("\n");
2928            let mut nested = MarkdownParser::new(&sub_text).parse_blocks()?;
2929            let mut content = vec![media];
2930            content.append(&mut nested);
2931            Ok(list_item_with_local_id(content, local_id, para_local_id))
2932        }
2933    } else {
2934        let first_node = AdfNode::paragraph(parse_inline(item_text));
2935        if sub_lines.is_empty() {
2936            Ok(list_item_with_local_id(
2937                vec![first_node],
2938                local_id,
2939                para_local_id,
2940            ))
2941        } else {
2942            let sub_text = sub_lines.join("\n");
2943            let mut nested = MarkdownParser::new(&sub_text).parse_blocks()?;
2944            let mut content = vec![first_node];
2945            content.append(&mut nested);
2946            Ok(list_item_with_local_id(content, local_id, para_local_id))
2947        }
2948    }
2949}
2950
2951fn list_item_with_local_id(
2952    mut content: Vec<AdfNode>,
2953    local_id: Option<String>,
2954    para_local_id: Option<String>,
2955) -> AdfNode {
2956    if let Some(id) = &para_local_id {
2957        if let Some(first) = content.first_mut() {
2958            if first.node_type == "paragraph" {
2959                let node_attrs = first.attrs.get_or_insert_with(|| serde_json::json!({}));
2960                node_attrs["localId"] = serde_json::Value::String(id.clone());
2961            }
2962        }
2963    }
2964    let mut item = AdfNode::list_item(content);
2965    if let Some(id) = local_id {
2966        item.attrs = Some(serde_json::json!({"localId": id}));
2967    }
2968    item
2969}
2970
2971fn maybe_push_local_id(attrs: &serde_json::Value, parts: &mut Vec<String>, opts: &RenderOptions) {
2972    if opts.strip_local_ids {
2973        return;
2974    }
2975    if let Some(local_id) = attrs.get("localId").and_then(serde_json::Value::as_str) {
2976        if !local_id.is_empty() && local_id != "00000000-0000-0000-0000-000000000000" {
2977            parts.push(format_kv("localId", local_id));
2978        }
2979    }
2980}
2981
2982/// Renders a sequence of block nodes with blank-line separators between them.
2983fn render_block_children(children: &[AdfNode], output: &mut String, opts: &RenderOptions) {
2984    for (i, child) in children.iter().enumerate() {
2985        if i > 0 {
2986            output.push('\n');
2987        }
2988        render_block_node(child, output, opts);
2989    }
2990}
2991
2992/// Formats a float as an integer string when it has no fractional part,
2993/// otherwise as a regular float string.
2994fn fmt_f64_attr(v: f64) -> String {
2995    if v.fract() == 0.0 {
2996        format!("{}", v as i64)
2997    } else {
2998        v.to_string()
2999    }
3000}
3001
3002/// Parses a numeric attribute value string into a JSON number value that
3003/// preserves the original integer/float distinction. Returns `None` if the
3004/// string cannot be parsed as a number.
3005///
3006/// Strings without a `.` or exponent are parsed as integers (so `"800"` stays
3007/// `800`, not `800.0`); strings with a decimal point are parsed as floats.
3008fn parse_numeric_attr(s: &str) -> Option<serde_json::Value> {
3009    if s.contains('.') || s.contains('e') || s.contains('E') {
3010        s.parse::<f64>().ok().map(serde_json::Value::from)
3011    } else {
3012        s.parse::<i64>().ok().map(serde_json::Value::from)
3013    }
3014}
3015
3016/// Formats a JSON numeric value as a markdown attribute string, preserving
3017/// whether the source was stored as an integer or a float.
3018///
3019/// Returns `None` if `v` is not a number. Integer values emit as `800`;
3020/// floating-point values emit as `800.0` (or `66.66` for non-integer floats),
3021/// so that a subsequent [`parse_numeric_attr`] round-trip recovers the same
3022/// JSON type.
3023fn fmt_numeric_attr(v: &serde_json::Value) -> Option<String> {
3024    if let Some(n) = v.as_i64() {
3025        return Some(n.to_string());
3026    }
3027    if let Some(n) = v.as_u64() {
3028        return Some(n.to_string());
3029    }
3030    if let Some(n) = v.as_f64() {
3031        if n.fract() == 0.0 && n.is_finite() {
3032            return Some(format!("{n:.1}"));
3033        }
3034        return Some(n.to_string());
3035    }
3036    None
3037}
3038
3039/// Renders a block-level ADF node to markdown.
3040fn render_block_node(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
3041    match node.node_type.as_str() {
3042        "paragraph" => {
3043            let is_empty = node.content.as_ref().map_or(true, Vec::is_empty);
3044            // Build directive attr string for localId when using ::paragraph form
3045            let dir_attrs = {
3046                let mut parts = Vec::new();
3047                if let Some(ref attrs) = node.attrs {
3048                    maybe_push_local_id(attrs, &mut parts, opts);
3049                }
3050                if parts.is_empty() {
3051                    String::new()
3052                } else {
3053                    format!("{{{}}}", parts.join(" "))
3054                }
3055            };
3056            if is_empty {
3057                output.push_str(&format!("::paragraph{dir_attrs}\n"));
3058            } else {
3059                // Render to a buffer first to check if content is whitespace-only
3060                let mut buf = String::new();
3061                render_inline_content(node, &mut buf, opts);
3062                if buf.trim().is_empty() && !buf.is_empty() {
3063                    // Whitespace-only content (e.g. NBSP) would be lost as a plain
3064                    // line — use the ::paragraph[content]{attrs} directive form
3065                    output.push_str(&format!("::paragraph[{buf}]{dir_attrs}\n"));
3066                } else {
3067                    // Escape a leading list-marker pattern so paragraph
3068                    // text is not re-parsed as a list item (issue #402).
3069                    // Indent continuation lines produced by hardBreaks so
3070                    // they are not re-parsed as list items (issue #455).
3071                    let mut is_first_line = true;
3072                    for line in buf.split('\n') {
3073                        if is_first_line {
3074                            if is_list_start(line) {
3075                                output.push_str(&escape_list_marker(line));
3076                            } else {
3077                                output.push_str(line);
3078                            }
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            }
3091        }
3092        "heading" => {
3093            let level = node
3094                .attrs
3095                .as_ref()
3096                .and_then(|a| a.get("level"))
3097                .and_then(serde_json::Value::as_u64)
3098                .unwrap_or(1);
3099            for _ in 0..level {
3100                output.push('#');
3101            }
3102            output.push(' ');
3103            let mut buf = String::new();
3104            render_inline_content(node, &mut buf, opts);
3105            // Indent continuation lines produced by hardBreaks so they stay
3106            // within the heading when re-parsed (issue #433).
3107            let mut is_first_line = true;
3108            for line in buf.split('\n') {
3109                if is_first_line {
3110                    output.push_str(line);
3111                    is_first_line = false;
3112                } else {
3113                    output.push('\n');
3114                    if !line.is_empty() {
3115                        output.push_str("  ");
3116                    }
3117                    output.push_str(line);
3118                }
3119            }
3120            output.push('\n');
3121        }
3122        "codeBlock" => {
3123            let language_value = node.attrs.as_ref().and_then(|a| a.get("language"));
3124            let language = language_value
3125                .and_then(serde_json::Value::as_str)
3126                .unwrap_or("");
3127            output.push_str("```");
3128            if language.is_empty() && language_value.is_some() {
3129                // Explicit empty language attr: encode as ```"" to distinguish
3130                // from a codeBlock with no attrs at all (plain ```).
3131                output.push_str("\"\"");
3132            } else {
3133                output.push_str(language);
3134            }
3135            output.push('\n');
3136            if let Some(ref content) = node.content {
3137                for child in content {
3138                    if let Some(ref text) = child.text {
3139                        output.push_str(text);
3140                    }
3141                }
3142            }
3143            output.push_str("\n```\n");
3144        }
3145        "blockquote" => {
3146            if let Some(ref content) = node.content {
3147                for (i, child) in content.iter().enumerate() {
3148                    // Separate consecutive paragraph siblings with a blank
3149                    // blockquote-prefixed line so they re-parse as distinct
3150                    // paragraphs rather than being merged into one (issue #531).
3151                    if i > 0
3152                        && child.node_type == "paragraph"
3153                        && content[i - 1].node_type == "paragraph"
3154                    {
3155                        output.push_str(">\n");
3156                    }
3157                    let mut inner = String::new();
3158                    render_block_node(child, &mut inner, opts);
3159                    for line in inner.lines() {
3160                        output.push_str("> ");
3161                        output.push_str(line);
3162                        output.push('\n');
3163                    }
3164                }
3165            }
3166        }
3167        "bulletList" => {
3168            if let Some(ref items) = node.content {
3169                for item in items {
3170                    output.push_str("- ");
3171                    let content_start = output.len();
3172                    render_list_item_content(item, output, opts);
3173                    // If the rendered content begins with a sequence the
3174                    // bullet-list parser would interpret as a task checkbox
3175                    // marker, escape the leading `[` so the round-trip
3176                    // preserves this as a `bulletList` rather than promoting
3177                    // it to a `taskList` (issue #548).
3178                    if starts_with_task_marker(&output[content_start..]) {
3179                        output.insert(content_start, '\\');
3180                    }
3181                }
3182            }
3183        }
3184        "orderedList" => {
3185            let start = node
3186                .attrs
3187                .as_ref()
3188                .and_then(|a| a.get("order"))
3189                .and_then(serde_json::Value::as_u64)
3190                .unwrap_or(1);
3191            if let Some(ref items) = node.content {
3192                for (i, item) in items.iter().enumerate() {
3193                    let num = start + i as u64;
3194                    output.push_str(&format!("{num}. "));
3195                    render_list_item_content(item, output, opts);
3196                }
3197            }
3198        }
3199        "taskList" => {
3200            if let Some(ref items) = node.content {
3201                for item in items {
3202                    if item.node_type == "taskList" {
3203                        // A nested taskList is a sibling child of the outer
3204                        // taskList — render it indented so it round-trips back
3205                        // as a taskList, not a taskItem (issue #506).
3206                        let mut nested = String::new();
3207                        render_block_node(item, &mut nested, opts);
3208                        for line in nested.lines() {
3209                            output.push_str("  ");
3210                            output.push_str(line);
3211                            output.push('\n');
3212                        }
3213                    } else {
3214                        let state = item
3215                            .attrs
3216                            .as_ref()
3217                            .and_then(|a| a.get("state"))
3218                            .and_then(serde_json::Value::as_str)
3219                            .unwrap_or("TODO");
3220                        if state == "DONE" {
3221                            output.push_str("- [x] ");
3222                        } else {
3223                            output.push_str("- [ ] ");
3224                        }
3225                        render_list_item_content(item, output, opts);
3226                    }
3227                }
3228            }
3229        }
3230        "rule" => {
3231            output.push_str("---\n");
3232        }
3233        "table" => {
3234            render_table(node, output, opts);
3235        }
3236        "mediaSingle" => {
3237            if let Some(ref content) = node.content {
3238                for child in content {
3239                    if child.node_type == "media" {
3240                        render_media(child, node.attrs.as_ref(), output, opts);
3241                    }
3242                }
3243                for child in content {
3244                    if child.node_type == "caption" {
3245                        let mut cap_parts = Vec::new();
3246                        if let Some(ref attrs) = child.attrs {
3247                            maybe_push_local_id(attrs, &mut cap_parts, opts);
3248                        }
3249                        if cap_parts.is_empty() {
3250                            output.push_str(":::caption\n");
3251                        } else {
3252                            output.push_str(&format!(":::caption{{{}}}\n", cap_parts.join(" ")));
3253                        }
3254                        if let Some(ref caption_content) = child.content {
3255                            for inline in caption_content {
3256                                render_inline_node(inline, output, opts);
3257                            }
3258                            output.push('\n');
3259                        }
3260                        output.push_str(":::\n");
3261                    }
3262                }
3263            }
3264        }
3265        "blockCard" => {
3266            if let Some(ref attrs) = node.attrs {
3267                let url = attrs
3268                    .get("url")
3269                    .and_then(serde_json::Value::as_str)
3270                    .unwrap_or("");
3271                let mut attr_parts = Vec::new();
3272                if url_safe_in_bracket_content(url) {
3273                    output.push_str(&format!("::card[{url}]"));
3274                } else {
3275                    // URL would break `::card[URL]` parsing; use quoted attr form.
3276                    output.push_str("::card[]");
3277                    let escaped = url.replace('\\', "\\\\").replace('"', "\\\"");
3278                    attr_parts.push(format!("url=\"{escaped}\""));
3279                }
3280                if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3281                    attr_parts.push(format!("layout={layout}"));
3282                }
3283                if let Some(width) = attrs.get("width").and_then(serde_json::Value::as_u64) {
3284                    attr_parts.push(format!("width={width}"));
3285                }
3286                if !attr_parts.is_empty() {
3287                    output.push_str(&format!("{{{}}}", attr_parts.join(" ")));
3288                }
3289                output.push('\n');
3290            }
3291        }
3292        "embedCard" => {
3293            if let Some(ref attrs) = node.attrs {
3294                let url = attrs
3295                    .get("url")
3296                    .and_then(serde_json::Value::as_str)
3297                    .unwrap_or("");
3298                output.push_str(&format!("::embed[{url}]"));
3299                let mut attr_parts = Vec::new();
3300                if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3301                    attr_parts.push(format!("layout={layout}"));
3302                }
3303                if let Some(h) = attrs
3304                    .get("originalHeight")
3305                    .and_then(serde_json::Value::as_f64)
3306                {
3307                    attr_parts.push(format!("originalHeight={}", fmt_f64_attr(h)));
3308                }
3309                if let Some(w) = attrs.get("width").and_then(serde_json::Value::as_f64) {
3310                    attr_parts.push(format!("width={}", fmt_f64_attr(w)));
3311                }
3312                if !attr_parts.is_empty() {
3313                    output.push_str(&format!("{{{}}}", attr_parts.join(" ")));
3314                }
3315                output.push('\n');
3316            }
3317        }
3318        "extension" => {
3319            if let Some(ref attrs) = node.attrs {
3320                let ext_type = attrs
3321                    .get("extensionType")
3322                    .and_then(serde_json::Value::as_str)
3323                    .unwrap_or("");
3324                let ext_key = attrs
3325                    .get("extensionKey")
3326                    .and_then(serde_json::Value::as_str)
3327                    .unwrap_or("");
3328                let mut attr_parts = vec![format!("type={ext_type}"), format!("key={ext_key}")];
3329                if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3330                    attr_parts.push(format!("layout={layout}"));
3331                }
3332                if let Some(params) = attrs.get("parameters") {
3333                    if let Ok(json_str) = serde_json::to_string(params) {
3334                        attr_parts.push(format!("params='{json_str}'"));
3335                    }
3336                }
3337                maybe_push_local_id(attrs, &mut attr_parts, opts);
3338                output.push_str(&format!("::extension{{{}}}\n", attr_parts.join(" ")));
3339            }
3340        }
3341        "panel" => {
3342            let panel_type = node
3343                .attrs
3344                .as_ref()
3345                .and_then(|a| a.get("panelType"))
3346                .and_then(serde_json::Value::as_str)
3347                .unwrap_or("info");
3348            let mut attr_parts = vec![format!("type={panel_type}")];
3349            if let Some(ref attrs) = node.attrs {
3350                if let Some(icon) = attrs.get("panelIcon").and_then(serde_json::Value::as_str) {
3351                    attr_parts.push(format!("icon=\"{icon}\""));
3352                }
3353                if let Some(color) = attrs.get("panelColor").and_then(serde_json::Value::as_str) {
3354                    attr_parts.push(format!("color=\"{color}\""));
3355                }
3356            }
3357            output.push_str(&format!(":::panel{{{}}}\n", attr_parts.join(" ")));
3358            if let Some(ref content) = node.content {
3359                render_block_children(content, output, opts);
3360            }
3361            output.push_str(":::\n");
3362        }
3363        "expand" | "nestedExpand" => {
3364            let directive_name = if node.node_type == "nestedExpand" {
3365                "nested-expand"
3366            } else {
3367                "expand"
3368            };
3369            let mut attr_parts = Vec::new();
3370            if let Some(t) = node
3371                .attrs
3372                .as_ref()
3373                .and_then(|a| a.get("title"))
3374                .and_then(serde_json::Value::as_str)
3375            {
3376                attr_parts.push(format!("title=\"{t}\""));
3377            }
3378            // Check top-level localId first, then fall back to attrs.localId
3379            if let Some(ref lid) = node.local_id {
3380                if !opts.strip_local_ids && lid != "00000000-0000-0000-0000-000000000000" {
3381                    attr_parts.push(format!("localId={lid}"));
3382                }
3383            } else if let Some(ref attrs) = node.attrs {
3384                maybe_push_local_id(attrs, &mut attr_parts, opts);
3385            }
3386            // Emit top-level parameters as params='...'
3387            if let Some(ref params) = node.parameters {
3388                if let Ok(json_str) = serde_json::to_string(params) {
3389                    attr_parts.push(format!("params='{json_str}'"));
3390                }
3391            }
3392            if attr_parts.is_empty() {
3393                output.push_str(&format!(":::{directive_name}\n"));
3394            } else {
3395                output.push_str(&format!(
3396                    ":::{directive_name}{{{}}}\n",
3397                    attr_parts.join(" ")
3398                ));
3399            }
3400            if let Some(ref content) = node.content {
3401                render_block_children(content, output, opts);
3402            }
3403            output.push_str(":::\n");
3404        }
3405        "layoutSection" => {
3406            output.push_str("::::layout\n");
3407            if let Some(ref content) = node.content {
3408                for child in content {
3409                    if child.node_type == "layoutColumn" {
3410                        let width_str = child
3411                            .attrs
3412                            .as_ref()
3413                            .and_then(|a| a.get("width"))
3414                            .and_then(fmt_numeric_attr)
3415                            .unwrap_or_else(|| "50".to_string());
3416                        let mut parts = vec![format!("width={width_str}")];
3417                        if let Some(ref attrs) = child.attrs {
3418                            maybe_push_local_id(attrs, &mut parts, opts);
3419                        }
3420                        output.push_str(&format!(":::column{{{}}}\n", parts.join(" ")));
3421                        if let Some(ref col_content) = child.content {
3422                            render_block_children(col_content, output, opts);
3423                        }
3424                        output.push_str(":::\n");
3425                    }
3426                }
3427            }
3428            output.push_str("::::\n");
3429        }
3430        "decisionList" => {
3431            output.push_str(":::decisions\n");
3432            if let Some(ref content) = node.content {
3433                for item in content {
3434                    output.push_str("- <> ");
3435                    render_list_item_content(item, output, opts);
3436                }
3437            }
3438            output.push_str(":::\n");
3439        }
3440        "bodiedExtension" => {
3441            if let Some(ref attrs) = node.attrs {
3442                let ext_type = attrs
3443                    .get("extensionType")
3444                    .and_then(serde_json::Value::as_str)
3445                    .unwrap_or("");
3446                let ext_key = attrs
3447                    .get("extensionKey")
3448                    .and_then(serde_json::Value::as_str)
3449                    .unwrap_or("");
3450                let mut attr_parts = vec![format!("type={ext_type}"), format!("key={ext_key}")];
3451                if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3452                    attr_parts.push(format!("layout={layout}"));
3453                }
3454                if let Some(params) = attrs.get("parameters") {
3455                    if let Ok(json_str) = serde_json::to_string(params) {
3456                        attr_parts.push(format!("params='{json_str}'"));
3457                    }
3458                }
3459                maybe_push_local_id(attrs, &mut attr_parts, opts);
3460                output.push_str(&format!(":::extension{{{}}}\n", attr_parts.join(" ")));
3461                if let Some(ref content) = node.content {
3462                    render_block_children(content, output, opts);
3463                }
3464                output.push_str(":::\n");
3465            }
3466        }
3467        _ => {
3468            // Preserve unsupported nodes as JSON in adf-unsupported code blocks
3469            if let Ok(json) = serde_json::to_string_pretty(node) {
3470                output.push_str("```adf-unsupported\n");
3471                output.push_str(&json);
3472                output.push_str("\n```\n");
3473            }
3474        }
3475    }
3476
3477    // Emit block-level attribute marks (align, indent, breakout) and localId
3478    let mut parts = Vec::new();
3479    if let Some(ref marks) = node.marks {
3480        for mark in marks {
3481            match mark.mark_type.as_str() {
3482                "alignment" => {
3483                    if let Some(align) = mark
3484                        .attrs
3485                        .as_ref()
3486                        .and_then(|a| a.get("align"))
3487                        .and_then(serde_json::Value::as_str)
3488                    {
3489                        parts.push(format!("align={align}"));
3490                    }
3491                }
3492                "indentation" => {
3493                    if let Some(level) = mark
3494                        .attrs
3495                        .as_ref()
3496                        .and_then(|a| a.get("level"))
3497                        .and_then(serde_json::Value::as_u64)
3498                    {
3499                        parts.push(format!("indent={level}"));
3500                    }
3501                }
3502                "breakout" => {
3503                    if let Some(mode) = mark
3504                        .attrs
3505                        .as_ref()
3506                        .and_then(|a| a.get("mode"))
3507                        .and_then(serde_json::Value::as_str)
3508                    {
3509                        parts.push(format!("breakout={mode}"));
3510                    }
3511                    if let Some(width) = mark
3512                        .attrs
3513                        .as_ref()
3514                        .and_then(|a| a.get("width"))
3515                        .and_then(serde_json::Value::as_u64)
3516                    {
3517                        parts.push(format!("breakoutWidth={width}"));
3518                    }
3519                }
3520                _ => {}
3521            }
3522        }
3523    }
3524    // Skip localId for node types that already include it in their directive attrs.
3525    // For paragraphs, localId is included in the ::paragraph directive when the
3526    // paragraph uses directive form (empty or whitespace-only content).
3527    let para_used_directive = node.node_type == "paragraph" && {
3528        let is_empty = node.content.as_ref().map_or(true, Vec::is_empty);
3529        if is_empty {
3530            true
3531        } else {
3532            let mut buf = String::new();
3533            render_inline_content(node, &mut buf, opts);
3534            buf.trim().is_empty() && !buf.is_empty()
3535        }
3536    };
3537    if !matches!(node.node_type.as_str(), "expand" | "nestedExpand") && !para_used_directive {
3538        if let Some(ref attrs) = node.attrs {
3539            maybe_push_local_id(attrs, &mut parts, opts);
3540        }
3541    }
3542    // orderedList with explicit `attrs.order=1` needs a trailing `{order=1}`
3543    // signal so the round-trip can distinguish explicit default from omitted
3544    // attrs (issue #547). Values other than 1 are already encoded by the
3545    // list marker, so no signal is needed.
3546    if node.node_type == "orderedList" {
3547        if let Some(ref attrs) = node.attrs {
3548            if attrs.get("order").and_then(serde_json::Value::as_u64) == Some(1) {
3549                parts.push("order=1".to_string());
3550            }
3551        }
3552    }
3553    if !parts.is_empty() {
3554        output.push_str(&format!("{{{}}}\n", parts.join(" ")));
3555    }
3556}
3557
3558/// Renders the content of a list item (unwraps the paragraph layer).
3559/// Nested block children (e.g. sub-lists) are indented with two spaces.
3560///
3561/// Some ADF producers (e.g. Confluence) emit `taskItem` content without a
3562/// paragraph wrapper — the inline nodes sit directly inside the item.  We
3563/// detect this by checking whether the first child is an inline node type
3564/// and, if so, render *all* leading inline children on the first line.
3565fn render_list_item_content(item: &AdfNode, output: &mut String, opts: &RenderOptions) {
3566    let Some(ref content) = item.content else {
3567        // Still emit localId and newline for items with no content (e.g. empty taskItem).
3568        let bare = AdfNode::text("");
3569        emit_list_item_local_ids(item, &bare, output, opts);
3570        output.push('\n');
3571        return;
3572    };
3573    if content.is_empty() {
3574        let bare = AdfNode::text("");
3575        emit_list_item_local_ids(item, &bare, output, opts);
3576        output.push('\n');
3577        return;
3578    }
3579    let first = &content[0];
3580    let rest_start;
3581    if first.node_type == "paragraph" {
3582        let mut buf = String::new();
3583        render_inline_content(first, &mut buf, opts);
3584        // A trailing hardBreak produces a trailing `\\\n` in the buffer.
3585        // Strip the final newline so it doesn't create a blank line after
3586        // the list item marker, which would split the list on re-parse
3587        // (issue #472).  The `\` is kept so round-trip preserves the
3588        // hardBreak, and `output.push('\n')` below supplies the terminator.
3589        let buf = buf.trim_end_matches('\n');
3590        // Indent continuation lines produced by hardBreaks so they stay
3591        // within the list item when re-parsed (issue #402).
3592        let mut is_first_line = true;
3593        for line in buf.split('\n') {
3594            if is_first_line {
3595                output.push_str(line);
3596                is_first_line = false;
3597            } else {
3598                output.push('\n');
3599                if !line.is_empty() {
3600                    output.push_str("  ");
3601                }
3602                output.push_str(line);
3603            }
3604        }
3605        // Emit paragraph + listItem localIds as trailing inline attrs on the first line
3606        emit_list_item_local_ids(item, first, output, opts);
3607        output.push('\n');
3608        rest_start = 1;
3609    } else if is_inline_node_type(&first.node_type) {
3610        // Inline nodes without a paragraph wrapper — render them directly.
3611        rest_start = content
3612            .iter()
3613            .position(|c| !is_inline_node_type(&c.node_type))
3614            .unwrap_or(content.len());
3615        let mut buf = String::new();
3616        for child in &content[..rest_start] {
3617            render_inline_node(child, &mut buf, opts);
3618        }
3619        // Indent continuation lines produced by hardBreaks so they stay
3620        // within the list item when re-parsed (issue #521).
3621        let buf = buf.trim_end_matches('\n');
3622        let mut is_first_line = true;
3623        for line in buf.split('\n') {
3624            if is_first_line {
3625                output.push_str(line);
3626                is_first_line = false;
3627            } else {
3628                output.push('\n');
3629                if !line.is_empty() {
3630                    output.push_str("  ");
3631                }
3632                output.push_str(line);
3633            }
3634        }
3635        // No paragraph wrapper — pass a bare node so paraLocalId is omitted.
3636        let bare = AdfNode::text("");
3637        emit_list_item_local_ids(item, &bare, output, opts);
3638        output.push('\n');
3639        // Any remaining children are block nodes — fall through to the
3640        // indented-block loop below.
3641    } else if first.node_type == "taskItem" {
3642        // Malformed ADF: taskItem.content contains nested taskItem nodes
3643        // directly (seen in some Confluence pages).  Render them as an
3644        // indented nested task list to preserve the data without
3645        // corrupting the surrounding structure (issue #489).
3646        let bare = AdfNode::text("");
3647        emit_list_item_local_ids(item, &bare, output, opts);
3648        output.push('\n');
3649        for child in content {
3650            if child.node_type == "taskItem" {
3651                let state = child
3652                    .attrs
3653                    .as_ref()
3654                    .and_then(|a| a.get("state"))
3655                    .and_then(serde_json::Value::as_str)
3656                    .unwrap_or("TODO");
3657                let marker = if state == "DONE" { "- [x] " } else { "- [ ] " };
3658                output.push_str("  ");
3659                output.push_str(marker);
3660                render_list_item_content(child, output, opts);
3661            } else {
3662                let mut nested = String::new();
3663                render_block_node(child, &mut nested, opts);
3664                for line in nested.lines() {
3665                    output.push_str("  ");
3666                    output.push_str(line);
3667                    output.push('\n');
3668                }
3669            }
3670        }
3671        return;
3672    } else {
3673        // Block-level first child (e.g. codeBlock, mediaSingle).
3674        // Render to a buffer so we can:
3675        //  1. Append listItem localId attrs to the first line (issue #525)
3676        //  2. Indent continuation lines so multi-line blocks stay inside
3677        //     the list item (issue #511)
3678        let mut buf = String::new();
3679        render_block_node(first, &mut buf, opts);
3680        let bare = AdfNode::text("");
3681        let mut is_first = true;
3682        for line in buf.lines() {
3683            if is_first {
3684                output.push_str(line);
3685                emit_list_item_local_ids(item, &bare, output, opts);
3686                output.push('\n');
3687                is_first = false;
3688            } else {
3689                output.push_str("  ");
3690                output.push_str(line);
3691                output.push('\n');
3692            }
3693        }
3694        rest_start = 1;
3695    }
3696    let rest = &content[rest_start..];
3697    for (i, child) in rest.iter().enumerate() {
3698        // Separate consecutive paragraph siblings with a blank indented
3699        // line so they re-parse as distinct paragraphs rather than being
3700        // merged into one (issue #522).
3701        if child.node_type == "paragraph" {
3702            let prev_is_para = if i == 0 {
3703                // First rest child — check whether the first-line node
3704                // (rendered above) was a paragraph.
3705                first.node_type == "paragraph"
3706            } else {
3707                rest[i - 1].node_type == "paragraph"
3708            };
3709            if prev_is_para {
3710                output.push_str("  \n");
3711            }
3712        }
3713        let mut nested = String::new();
3714        render_block_node(child, &mut nested, opts);
3715        for line in nested.lines() {
3716            output.push_str("  ");
3717            output.push_str(line);
3718            output.push('\n');
3719        }
3720    }
3721}
3722
3723/// Returns `true` if the given ADF node type is an inline node.
3724fn is_inline_node_type(node_type: &str) -> bool {
3725    matches!(
3726        node_type,
3727        "text"
3728            | "hardBreak"
3729            | "inlineCard"
3730            | "emoji"
3731            | "mention"
3732            | "status"
3733            | "date"
3734            | "placeholder"
3735            | "mediaInline"
3736    )
3737}
3738
3739/// Emits trailing `{localId=... paraLocalId=...}` on a list item line
3740/// for both the listItem and its first (unwrapped) paragraph.
3741fn emit_list_item_local_ids(
3742    item: &AdfNode,
3743    paragraph: &AdfNode,
3744    output: &mut String,
3745    opts: &RenderOptions,
3746) {
3747    if opts.strip_local_ids {
3748        return;
3749    }
3750    let mut parts = Vec::new();
3751    if let Some(ref attrs) = item.attrs {
3752        maybe_push_local_id(attrs, &mut parts, opts);
3753    }
3754    if paragraph.node_type == "paragraph" {
3755        let has_real_id = paragraph
3756            .attrs
3757            .as_ref()
3758            .and_then(|a| a.get("localId"))
3759            .and_then(serde_json::Value::as_str)
3760            .filter(|id| !id.is_empty() && *id != "00000000-0000-0000-0000-000000000000");
3761        if let Some(local_id) = has_real_id {
3762            parts.push(format!("paraLocalId={local_id}"));
3763        } else if item.node_type == "taskItem" {
3764            // taskItem content may or may not have a paragraph wrapper;
3765            // emit a sentinel so the round-trip can distinguish the two
3766            // forms and restore the wrapper (issue #478).
3767            parts.push("paraLocalId=_".to_string());
3768        }
3769    }
3770    if !parts.is_empty() {
3771        output.push_str(&format!(" {{{}}}", parts.join(" ")));
3772    }
3773}
3774
3775/// Renders a table node, choosing between pipe table and directive table form.
3776fn render_table(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
3777    let Some(ref rows) = node.content else {
3778        return;
3779    };
3780
3781    if table_qualifies_for_pipe_syntax(rows) {
3782        render_pipe_table(node, rows, output, opts);
3783    } else {
3784        render_directive_table(node, rows, output, opts);
3785    }
3786}
3787
3788/// Checks whether all cells qualify for GFM pipe table syntax:
3789/// - Every cell has exactly one paragraph child with only inline nodes
3790/// - All `tableHeader` nodes appear exclusively in the first row
3791/// - The first row must contain at least one `tableHeader` (pipe tables
3792///   always treat the first row as headers, so `tableCell`-only first rows
3793///   must use directive form to preserve the cell type)
3794fn table_qualifies_for_pipe_syntax(rows: &[AdfNode]) -> bool {
3795    // Tables with caption nodes must use directive form
3796    if rows.iter().any(|n| n.node_type == "caption") {
3797        return false;
3798    }
3799    let mut first_row_has_header = false;
3800    for (row_idx, row) in rows.iter().enumerate() {
3801        let Some(ref cells) = row.content else {
3802            continue;
3803        };
3804        for cell in cells {
3805            // Header cells outside first row → must use directive form
3806            if row_idx > 0 && cell.node_type == "tableHeader" {
3807                return false;
3808            }
3809            if row_idx == 0 && cell.node_type == "tableHeader" {
3810                first_row_has_header = true;
3811            }
3812            // Check cell has exactly one paragraph with only inline content
3813            let Some(ref content) = cell.content else {
3814                continue;
3815            };
3816            if content.len() != 1 || content[0].node_type != "paragraph" {
3817                return false;
3818            }
3819            // hardBreak inside a cell produces a newline that breaks pipe
3820            // table syntax — fall back to directive form
3821            if cell_contains_hard_break(&content[0]) {
3822                return false;
3823            }
3824            // Cell-level marks (e.g., border) cannot be represented in pipe
3825            // form — fall back to directive form
3826            if cell.marks.as_ref().is_some_and(|m| !m.is_empty()) {
3827                return false;
3828            }
3829            // Paragraph-level localId would be lost in pipe form (the paragraph
3830            // is unwrapped into the cell text) — fall back to directive form
3831            if content[0]
3832                .attrs
3833                .as_ref()
3834                .and_then(|a| a.get("localId"))
3835                .is_some()
3836            {
3837                return false;
3838            }
3839        }
3840    }
3841    // First row must have at least one tableHeader for pipe syntax;
3842    // otherwise the round-trip would convert tableCell → tableHeader.
3843    first_row_has_header
3844}
3845
3846/// Returns true if a paragraph node contains any `hardBreak` inline nodes.
3847fn cell_contains_hard_break(paragraph: &AdfNode) -> bool {
3848    paragraph
3849        .content
3850        .as_ref()
3851        .is_some_and(|nodes| nodes.iter().any(|n| n.node_type == "hardBreak"))
3852}
3853
3854/// Renders a table as GFM pipe syntax.
3855fn render_pipe_table(node: &AdfNode, rows: &[AdfNode], output: &mut String, opts: &RenderOptions) {
3856    for (row_idx, row) in rows.iter().enumerate() {
3857        let Some(ref cells) = row.content else {
3858            continue;
3859        };
3860
3861        output.push('|');
3862        for cell in cells {
3863            output.push(' ');
3864            let mut cell_buf = String::new();
3865            render_cell_attrs_prefix(cell, &mut cell_buf);
3866            render_inline_content_from_first_paragraph(cell, &mut cell_buf, opts);
3867            output.push_str(&escape_pipes_in_cell(&cell_buf));
3868            output.push_str(" |");
3869        }
3870        output.push('\n');
3871
3872        // Add separator after header row
3873        if row_idx == 0 {
3874            output.push('|');
3875            for cell in cells {
3876                let align = get_cell_paragraph_alignment(cell);
3877                match align {
3878                    Some("center") => output.push_str(" :---: |"),
3879                    Some("end") => output.push_str(" ---: |"),
3880                    _ => output.push_str(" --- |"),
3881                }
3882            }
3883            output.push('\n');
3884        }
3885    }
3886
3887    // Emit table-level attrs if present
3888    render_table_level_attrs(node, output, opts);
3889}
3890
3891/// Renders a table as `::::table` directive syntax (block-content cells).
3892fn render_directive_table(
3893    node: &AdfNode,
3894    rows: &[AdfNode],
3895    output: &mut String,
3896    opts: &RenderOptions,
3897) {
3898    // Opening fence with attrs
3899    let mut attr_parts = Vec::new();
3900    if let Some(ref attrs) = node.attrs {
3901        if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3902            attr_parts.push(format!("layout={layout}"));
3903        }
3904        if let Some(numbered) = attrs
3905            .get("isNumberColumnEnabled")
3906            .and_then(serde_json::Value::as_bool)
3907        {
3908            if numbered {
3909                attr_parts.push("numbered".to_string());
3910            } else {
3911                attr_parts.push("numbered=false".to_string());
3912            }
3913        }
3914        if let Some(tw) = attrs.get("width").and_then(serde_json::Value::as_f64) {
3915            let tw_str = if tw.fract() == 0.0 {
3916                (tw as u64).to_string()
3917            } else {
3918                tw.to_string()
3919            };
3920            attr_parts.push(format!("width={tw_str}"));
3921        }
3922        maybe_push_local_id(attrs, &mut attr_parts, opts);
3923    }
3924    if attr_parts.is_empty() {
3925        output.push_str("::::table\n");
3926    } else {
3927        output.push_str(&format!("::::table{{{}}}\n", attr_parts.join(" ")));
3928    }
3929
3930    for row in rows {
3931        if row.node_type == "caption" {
3932            let mut cap_parts = Vec::new();
3933            if let Some(ref attrs) = row.attrs {
3934                maybe_push_local_id(attrs, &mut cap_parts, opts);
3935            }
3936            if cap_parts.is_empty() {
3937                output.push_str(":::caption\n");
3938            } else {
3939                output.push_str(&format!(":::caption{{{}}}\n", cap_parts.join(" ")));
3940            }
3941            if let Some(ref content) = row.content {
3942                for child in content {
3943                    render_inline_node(child, output, opts);
3944                }
3945                output.push('\n');
3946            }
3947            output.push_str(":::\n");
3948            continue;
3949        }
3950        let Some(ref cells) = row.content else {
3951            continue;
3952        };
3953        // Emit :::tr with optional localId
3954        let mut tr_attrs = Vec::new();
3955        if let Some(ref attrs) = row.attrs {
3956            maybe_push_local_id(attrs, &mut tr_attrs, opts);
3957        }
3958        if tr_attrs.is_empty() {
3959            output.push_str(":::tr\n");
3960        } else {
3961            output.push_str(&format!(":::tr{{{}}}\n", tr_attrs.join(" ")));
3962        }
3963        for cell in cells {
3964            let directive_name = if cell.node_type == "tableHeader" {
3965                "th"
3966            } else {
3967                "td"
3968            };
3969            let mut cell_attr_str = build_cell_attrs_string(cell);
3970            // Append localId to cell attrs if present
3971            if let Some(ref attrs) = cell.attrs {
3972                let mut lid_parts = Vec::new();
3973                maybe_push_local_id(attrs, &mut lid_parts, opts);
3974                if !lid_parts.is_empty() {
3975                    if !cell_attr_str.is_empty() {
3976                        cell_attr_str.push(' ');
3977                    }
3978                    cell_attr_str.push_str(&lid_parts.join(" "));
3979                }
3980            }
3981            // Append border mark attrs if present
3982            if let Some(ref marks) = cell.marks {
3983                for mark in marks {
3984                    if mark.mark_type == "border" {
3985                        if let Some(ref attrs) = mark.attrs {
3986                            if let Some(color) =
3987                                attrs.get("color").and_then(serde_json::Value::as_str)
3988                            {
3989                                if !cell_attr_str.is_empty() {
3990                                    cell_attr_str.push(' ');
3991                                }
3992                                cell_attr_str.push_str(&format!("border-color={color}"));
3993                            }
3994                            if let Some(size) =
3995                                attrs.get("size").and_then(serde_json::Value::as_u64)
3996                            {
3997                                if !cell_attr_str.is_empty() {
3998                                    cell_attr_str.push(' ');
3999                                }
4000                                cell_attr_str.push_str(&format!("border-size={size}"));
4001                            }
4002                        }
4003                    }
4004                }
4005            }
4006            let has_marks = cell.marks.as_ref().is_some_and(|m| !m.is_empty());
4007            if cell_attr_str.is_empty() && cell.attrs.is_none() && !has_marks {
4008                output.push_str(&format!(":::{directive_name}\n"));
4009            } else {
4010                output.push_str(&format!(":::{directive_name}{{{cell_attr_str}}}\n"));
4011            }
4012            if let Some(ref content) = cell.content {
4013                render_block_children(content, output, opts);
4014            }
4015            output.push_str(":::\n");
4016        }
4017        output.push_str(":::\n");
4018    }
4019
4020    output.push_str("::::\n");
4021}
4022
4023/// Returns `true` when an attribute value must be quoted to survive round-trip
4024/// through the `{key=value}` attribute parser (which stops unquoted values at
4025/// whitespace or `}`).
4026fn needs_attr_quoting(value: &str) -> bool {
4027    value.contains(|c: char| c.is_whitespace() || c == '}' || c == '(' || c == ')' || c == ',')
4028}
4029
4030/// Builds a JFM attribute string from ADF cell attributes.
4031fn build_cell_attrs_string(cell: &AdfNode) -> String {
4032    let Some(ref attrs) = cell.attrs else {
4033        return String::new();
4034    };
4035    let mut parts = Vec::new();
4036    if let Some(colspan) = attrs.get("colspan").and_then(serde_json::Value::as_u64) {
4037        parts.push(format!("colspan={colspan}"));
4038    }
4039    if let Some(rowspan) = attrs.get("rowspan").and_then(serde_json::Value::as_u64) {
4040        parts.push(format!("rowspan={rowspan}"));
4041    }
4042    if let Some(bg) = attrs.get("background").and_then(serde_json::Value::as_str) {
4043        if needs_attr_quoting(bg) {
4044            let escaped = bg.replace('\\', "\\\\").replace('"', "\\\"");
4045            parts.push(format!("bg=\"{escaped}\""));
4046        } else {
4047            parts.push(format!("bg={bg}"));
4048        }
4049    }
4050    if let Some(colwidth) = attrs.get("colwidth").and_then(serde_json::Value::as_array) {
4051        let widths: Vec<String> = colwidth
4052            .iter()
4053            .filter_map(|v| {
4054                // Preserve the original number type: integers stay as integers,
4055                // floats stay as floats (e.g. Confluence's 254.0 representation).
4056                if let Some(n) = v.as_u64() {
4057                    Some(n.to_string())
4058                } else if let Some(n) = v.as_f64() {
4059                    if n.fract() == 0.0 {
4060                        format!("{n:.1}")
4061                    } else {
4062                        n.to_string()
4063                    }
4064                    .into()
4065                } else {
4066                    None
4067                }
4068            })
4069            .collect();
4070        if !widths.is_empty() {
4071            parts.push(format!("colwidth={}", widths.join(",")));
4072        }
4073    }
4074    parts.join(" ")
4075}
4076
4077/// Renders `{attrs}` prefix for a pipe table cell (background, colspan, etc.).
4078fn render_cell_attrs_prefix(cell: &AdfNode, output: &mut String) {
4079    let Some(ref _attrs) = cell.attrs else {
4080        return;
4081    };
4082    let attr_str = build_cell_attrs_string(cell);
4083    if attr_str.is_empty() {
4084        output.push_str("{} ");
4085    } else {
4086        output.push_str(&format!("{{{attr_str}}} "));
4087    }
4088}
4089
4090/// Gets the alignment mark value from the paragraph inside a table cell.
4091fn get_cell_paragraph_alignment(cell: &AdfNode) -> Option<&str> {
4092    let content = cell.content.as_ref()?;
4093    let para = content.first()?;
4094    let marks = para.marks.as_ref()?;
4095    marks.iter().find_map(|m| {
4096        if m.mark_type == "alignment" {
4097            m.attrs
4098                .as_ref()
4099                .and_then(|a| a.get("align"))
4100                .and_then(serde_json::Value::as_str)
4101        } else {
4102            None
4103        }
4104    })
4105}
4106
4107/// Emits table-level attributes if present.
4108fn render_table_level_attrs(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4109    if let Some(ref attrs) = node.attrs {
4110        let mut parts = Vec::new();
4111        if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
4112            parts.push(format!("layout={layout}"));
4113        }
4114        if let Some(numbered) = attrs
4115            .get("isNumberColumnEnabled")
4116            .and_then(serde_json::Value::as_bool)
4117        {
4118            if numbered {
4119                parts.push("numbered".to_string());
4120            } else {
4121                parts.push("numbered=false".to_string());
4122            }
4123        }
4124        if let Some(tw_str) = attrs.get("width").and_then(fmt_numeric_attr) {
4125            parts.push(format!("width={tw_str}"));
4126        }
4127        maybe_push_local_id(attrs, &mut parts, opts);
4128        if !parts.is_empty() {
4129            output.push_str(&format!("{{{}}}\n", parts.join(" ")));
4130        }
4131    }
4132}
4133
4134/// Renders inline content from the first paragraph child of a table cell.
4135fn render_inline_content_from_first_paragraph(
4136    cell: &AdfNode,
4137    output: &mut String,
4138    opts: &RenderOptions,
4139) {
4140    if let Some(ref content) = cell.content {
4141        if let Some(first) = content.first() {
4142            if first.node_type == "paragraph" {
4143                render_inline_content(first, output, opts);
4144            }
4145        }
4146    }
4147}
4148
4149/// Appends border mark attributes (border-color, border-size) to a parts vec.
4150fn push_border_mark_attrs(marks: &Option<Vec<AdfMark>>, parts: &mut Vec<String>) {
4151    if let Some(ref marks) = marks {
4152        for mark in marks {
4153            if mark.mark_type == "border" {
4154                if let Some(ref attrs) = mark.attrs {
4155                    if let Some(color) = attrs.get("color").and_then(serde_json::Value::as_str) {
4156                        parts.push(format!("border-color={color}"));
4157                    }
4158                    if let Some(size) = attrs.get("size").and_then(serde_json::Value::as_u64) {
4159                        parts.push(format!("border-size={size}"));
4160                    }
4161                }
4162            }
4163        }
4164    }
4165}
4166
4167/// Renders a media node as a markdown image, with optional parent (mediaSingle) attrs.
4168fn render_media(
4169    node: &AdfNode,
4170    parent_attrs: Option<&serde_json::Value>,
4171    output: &mut String,
4172    opts: &RenderOptions,
4173) {
4174    if let Some(ref attrs) = node.attrs {
4175        let media_type = attrs
4176            .get("type")
4177            .and_then(serde_json::Value::as_str)
4178            .unwrap_or("external");
4179        let alt = attrs
4180            .get("alt")
4181            .and_then(serde_json::Value::as_str)
4182            .unwrap_or("");
4183
4184        if media_type == "file" {
4185            // Confluence file attachment — encode metadata in {attrs} block so it survives round-trip
4186            output.push_str(&format!("![{alt}]()"));
4187            let mut parts = vec!["type=file".to_string()];
4188            if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4189                parts.push(format_kv("id", id));
4190            }
4191            if let Some(collection) = attrs.get("collection").and_then(serde_json::Value::as_str) {
4192                parts.push(format_kv("collection", collection));
4193            }
4194            if let Some(occurrence_key) = attrs
4195                .get("occurrenceKey")
4196                .and_then(serde_json::Value::as_str)
4197            {
4198                parts.push(format_kv("occurrenceKey", occurrence_key));
4199            }
4200            if let Some(height_str) = attrs.get("height").and_then(fmt_numeric_attr) {
4201                parts.push(format!("height={height_str}"));
4202            }
4203            if let Some(width_str) = attrs.get("width").and_then(fmt_numeric_attr) {
4204                parts.push(format!("width={width_str}"));
4205            }
4206            maybe_push_local_id(attrs, &mut parts, opts);
4207            // Encode mediaSingle layout/width/widthType if non-default
4208            if let Some(p_attrs) = parent_attrs {
4209                if let Some(layout) = p_attrs.get("layout").and_then(serde_json::Value::as_str) {
4210                    if layout != "center" {
4211                        parts.push(format!("layout={layout}"));
4212                    }
4213                }
4214                if let Some(ms_width_str) = p_attrs.get("width").and_then(fmt_numeric_attr) {
4215                    parts.push(format!("mediaWidth={ms_width_str}"));
4216                }
4217                if let Some(wt) = p_attrs.get("widthType").and_then(serde_json::Value::as_str) {
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            push_border_mark_attrs(&node.marks, &mut parts);
4225            output.push_str(&format!("{{{}}}", parts.join(" ")));
4226        } else {
4227            // External image
4228            let url = attrs
4229                .get("url")
4230                .and_then(serde_json::Value::as_str)
4231                .unwrap_or("");
4232            output.push_str(&format!("![{alt}]({url})"));
4233
4234            // Emit {layout=... width=... widthType=... mode=... localId=...} if non-default attrs present
4235            {
4236                let mut parts = Vec::new();
4237                if let Some(p_attrs) = parent_attrs {
4238                    let layout = p_attrs.get("layout").and_then(serde_json::Value::as_str);
4239                    let width_str = p_attrs.get("width").and_then(fmt_numeric_attr);
4240                    let width_type = p_attrs.get("widthType").and_then(serde_json::Value::as_str);
4241                    if let Some(l) = layout {
4242                        if l != "center" {
4243                            parts.push(format!("layout={l}"));
4244                        }
4245                    }
4246                    if let Some(w) = width_str {
4247                        parts.push(format!("width={w}"));
4248                    }
4249                    if let Some(wt) = width_type {
4250                        parts.push(format!("widthType={wt}"));
4251                    }
4252                    if let Some(mode) = p_attrs.get("mode").and_then(serde_json::Value::as_str) {
4253                        parts.push(format!("mode={mode}"));
4254                    }
4255                }
4256                maybe_push_local_id(attrs, &mut parts, opts);
4257                push_border_mark_attrs(&node.marks, &mut parts);
4258                if !parts.is_empty() {
4259                    output.push_str(&format!("{{{}}}", parts.join(" ")));
4260                }
4261            }
4262        }
4263
4264        output.push('\n');
4265    }
4266}
4267
4268/// Renders inline content (text nodes with marks) from a block node's children.
4269fn render_inline_content(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4270    if let Some(ref content) = node.content {
4271        for child in content {
4272            render_inline_node(child, output, opts);
4273        }
4274    }
4275}
4276
4277/// Renders a single inline ADF node to markdown.
4278fn render_inline_node(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4279    match node.node_type.as_str() {
4280        "text" => {
4281            let text = node.text.as_deref().unwrap_or("");
4282            let marks = node.marks.as_deref().unwrap_or(&[]);
4283            let has_code = marks.iter().any(|m| m.mark_type == "code");
4284            // Issue #477: Escape literal backslashes before the newline
4285            // encoding below so they are not consumed as JFM escape
4286            // sequences on round-trip.  Code marks emit content verbatim,
4287            // so backslash escaping is skipped for them.
4288            let owned;
4289            let text = if !has_code {
4290                owned = text.replace('\\', "\\\\");
4291                owned.as_str()
4292            } else {
4293                text
4294            };
4295            // Issue #454: A literal newline inside a text node is escaped
4296            // as the two-character sequence `\n` so it survives round-trip
4297            // as a single text node instead of splitting into paragraphs or
4298            // being converted to hardBreak nodes.
4299            let owned_nl;
4300            let text = if text.contains('\n') {
4301                owned_nl = text.replace('\n', "\\n");
4302                owned_nl.as_str()
4303            } else {
4304                text
4305            };
4306            // Issue #510: Two or more trailing spaces at the end of a text
4307            // node would be misinterpreted as a hardBreak marker on
4308            // round-trip (and collapse the following paragraph).  Escape the
4309            // last space with a backslash so the parser treats it as a
4310            // literal space instead of a line-break signal.
4311            let owned_ts;
4312            let text = if !has_code && text.ends_with("  ") {
4313                let mut s = text.to_string();
4314                // Insert backslash before the final space: "foo  " → "foo \ "
4315                s.insert(s.len() - 1, '\\');
4316                owned_ts = s;
4317                owned_ts.as_str()
4318            } else {
4319                text
4320            };
4321            render_marked_text(text, marks, output);
4322        }
4323        "hardBreak" => {
4324            output.push_str("\\\n");
4325        }
4326        other => {
4327            // Issue #471: Non-text inline nodes (emoji, status, date, mention, etc.)
4328            // may carry annotation marks. Render the node body first, then wrap it
4329            // in bracketed-span syntax if annotation marks are present.
4330            let mut body = String::new();
4331            render_non_text_inline_body(other, node, &mut body, opts);
4332
4333            let annotations: Vec<&AdfMark> = node
4334                .marks
4335                .as_deref()
4336                .unwrap_or(&[])
4337                .iter()
4338                .filter(|m| m.mark_type == "annotation")
4339                .collect();
4340
4341            if annotations.is_empty() {
4342                output.push_str(&body);
4343            } else {
4344                let mut attr_parts = Vec::new();
4345                for ann in &annotations {
4346                    if let Some(ref attrs) = ann.attrs {
4347                        if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4348                            let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4349                            attr_parts.push(format!("annotation-id=\"{escaped}\""));
4350                        }
4351                        if let Some(at) = attrs
4352                            .get("annotationType")
4353                            .and_then(serde_json::Value::as_str)
4354                        {
4355                            attr_parts.push(format!("annotation-type={at}"));
4356                        }
4357                    }
4358                }
4359                output.push('[');
4360                output.push_str(&body);
4361                output.push_str("]{");
4362                output.push_str(&attr_parts.join(" "));
4363                output.push('}');
4364            }
4365        }
4366    }
4367}
4368
4369/// Renders the body of a non-text inline node (without mark wrapping).
4370fn render_non_text_inline_body(
4371    node_type: &str,
4372    node: &AdfNode,
4373    output: &mut String,
4374    opts: &RenderOptions,
4375) {
4376    match node_type {
4377        "inlineCard" => {
4378            if let Some(ref attrs) = node.attrs {
4379                if let Some(url) = attrs.get("url").and_then(serde_json::Value::as_str) {
4380                    let mut attr_parts = Vec::new();
4381                    if url_safe_in_bracket_content(url) {
4382                        output.push_str(":card[");
4383                        output.push_str(url);
4384                        output.push(']');
4385                    } else {
4386                        // URL would break `:card[URL]` parsing (e.g. contains an
4387                        // unbalanced `]` or a newline).  Fall back to quoted
4388                        // attribute form so the URL round-trips losslessly.
4389                        output.push_str(":card[]");
4390                        let escaped = url.replace('\\', "\\\\").replace('"', "\\\"");
4391                        attr_parts.push(format!("url=\"{escaped}\""));
4392                    }
4393                    maybe_push_local_id(attrs, &mut attr_parts, opts);
4394                    if !attr_parts.is_empty() {
4395                        output.push('{');
4396                        output.push_str(&attr_parts.join(" "));
4397                        output.push('}');
4398                    }
4399                }
4400            }
4401        }
4402        "emoji" => {
4403            if let Some(ref attrs) = node.attrs {
4404                if let Some(short_name) = attrs.get("shortName").and_then(serde_json::Value::as_str)
4405                {
4406                    output.push(':');
4407                    let name = short_name.strip_prefix(':').unwrap_or(short_name);
4408                    let name = name.strip_suffix(':').unwrap_or(name);
4409                    output.push_str(name);
4410                    output.push(':');
4411
4412                    let mut parts = Vec::new();
4413                    let escaped_sn = short_name.replace('\\', "\\\\").replace('"', "\\\"");
4414                    parts.push(format!("shortName=\"{escaped_sn}\""));
4415                    if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4416                        let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4417                        parts.push(format!("id=\"{escaped}\""));
4418                    }
4419                    if let Some(text) = attrs.get("text").and_then(serde_json::Value::as_str) {
4420                        let escaped = text.replace('\\', "\\\\").replace('"', "\\\"");
4421                        parts.push(format!("text=\"{escaped}\""));
4422                    }
4423                    maybe_push_local_id(attrs, &mut parts, opts);
4424                    output.push('{');
4425                    output.push_str(&parts.join(" "));
4426                    output.push('}');
4427                }
4428            }
4429        }
4430        "status" => {
4431            if let Some(ref attrs) = node.attrs {
4432                let text = attrs
4433                    .get("text")
4434                    .and_then(serde_json::Value::as_str)
4435                    .unwrap_or("");
4436                let color = attrs
4437                    .get("color")
4438                    .and_then(serde_json::Value::as_str)
4439                    .unwrap_or("neutral");
4440                let mut attr_parts = vec![format!("color={color}")];
4441                if let Some(style) = attrs.get("style").and_then(serde_json::Value::as_str) {
4442                    attr_parts.push(format!("style={style}"));
4443                }
4444                maybe_push_local_id(attrs, &mut attr_parts, opts);
4445                output.push_str(&format!(":status[{text}]{{{}}}", attr_parts.join(" ")));
4446            }
4447        }
4448        "date" => {
4449            if let Some(ref attrs) = node.attrs {
4450                if let Some(timestamp) = attrs.get("timestamp").and_then(serde_json::Value::as_str)
4451                {
4452                    let display = epoch_ms_to_iso_date(timestamp);
4453                    let mut attr_parts = vec![format!("timestamp={timestamp}")];
4454                    maybe_push_local_id(attrs, &mut attr_parts, opts);
4455                    output.push_str(&format!(":date[{display}]{{{}}}", attr_parts.join(" ")));
4456                }
4457            }
4458        }
4459        "mention" => {
4460            if let Some(ref attrs) = node.attrs {
4461                let id = attrs
4462                    .get("id")
4463                    .and_then(serde_json::Value::as_str)
4464                    .unwrap_or("");
4465                let text = attrs
4466                    .get("text")
4467                    .and_then(serde_json::Value::as_str)
4468                    .unwrap_or("");
4469                let mut attr_parts = vec![format!("id={id}")];
4470                if let Some(ut) = attrs.get("userType").and_then(serde_json::Value::as_str) {
4471                    attr_parts.push(format!("userType={ut}"));
4472                }
4473                if let Some(al) = attrs.get("accessLevel").and_then(serde_json::Value::as_str) {
4474                    attr_parts.push(format!("accessLevel={al}"));
4475                }
4476                maybe_push_local_id(attrs, &mut attr_parts, opts);
4477                output.push_str(&format!(":mention[{text}]{{{}}}", attr_parts.join(" ")));
4478            }
4479        }
4480        "placeholder" => {
4481            if let Some(ref attrs) = node.attrs {
4482                let text = attrs
4483                    .get("text")
4484                    .and_then(serde_json::Value::as_str)
4485                    .unwrap_or("");
4486                output.push_str(&format!(":placeholder[{text}]"));
4487            }
4488        }
4489        "inlineExtension" => {
4490            if let Some(ref attrs) = node.attrs {
4491                let ext_type = attrs
4492                    .get("extensionType")
4493                    .and_then(serde_json::Value::as_str)
4494                    .unwrap_or("");
4495                let ext_key = attrs
4496                    .get("extensionKey")
4497                    .and_then(serde_json::Value::as_str)
4498                    .unwrap_or("");
4499                let fallback = node.text.as_deref().unwrap_or("");
4500                output.push_str(&format!(
4501                    ":extension[{fallback}]{{type={ext_type} key={ext_key}}}"
4502                ));
4503            }
4504        }
4505        "mediaInline" => {
4506            if let Some(ref attrs) = node.attrs {
4507                let mut attr_parts = Vec::new();
4508                if let Some(media_type) = attrs.get("type").and_then(serde_json::Value::as_str) {
4509                    attr_parts.push(format_kv("type", media_type));
4510                }
4511                if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4512                    attr_parts.push(format_kv("id", id));
4513                }
4514                if let Some(collection) =
4515                    attrs.get("collection").and_then(serde_json::Value::as_str)
4516                {
4517                    attr_parts.push(format_kv("collection", collection));
4518                }
4519                if let Some(url) = attrs.get("url").and_then(serde_json::Value::as_str) {
4520                    attr_parts.push(format_kv("url", url));
4521                }
4522                if let Some(alt) = attrs.get("alt").and_then(serde_json::Value::as_str) {
4523                    attr_parts.push(format_kv("alt", alt));
4524                }
4525                if let Some(width) = attrs.get("width").and_then(serde_json::Value::as_u64) {
4526                    attr_parts.push(format!("width={width}"));
4527                }
4528                if let Some(height) = attrs.get("height").and_then(serde_json::Value::as_u64) {
4529                    attr_parts.push(format!("height={height}"));
4530                }
4531                maybe_push_local_id(attrs, &mut attr_parts, opts);
4532                output.push_str(&format!(":media-inline[]{{{}}}", attr_parts.join(" ")));
4533            }
4534        }
4535        _ => {
4536            output.push_str(&format!("<!-- unsupported inline: {} -->", node.node_type));
4537        }
4538    }
4539}
4540
4541/// Renders text with ADF marks applied as markdown syntax.
4542///
4543/// Mark ordering is preserved by walking the marks array in order and emitting
4544/// one wrapper per mark (outermost first, innermost last).  The resulting
4545/// markdown round-trips back to the original mark sequence because the parser
4546/// reconstructs marks outside-in from the nested delimiter structure.
4547///
4548/// When both `strong` and `em` are present, em is rendered with `_` instead of
4549/// `*` to avoid ambiguity (e.g., `_**text**_` rather than `***text***`).  The
4550/// single exception is `[strong, em]` (exactly those two marks in that order),
4551/// which is rendered as `***text***` to preserve the familiar compact form;
4552/// the parser's triple-delimiter rule round-trips it back to `[strong, em]`.
4553fn render_marked_text(text: &str, marks: &[AdfMark], output: &mut String) {
4554    if marks.iter().any(|m| m.mark_type == "code") {
4555        render_code_marked_text(text, marks, output);
4556        return;
4557    }
4558
4559    let has_link = marks.iter().any(|m| m.mark_type == "link");
4560    let has_strong = marks.iter().any(|m| m.mark_type == "strong");
4561    let has_em = marks.iter().any(|m| m.mark_type == "em");
4562
4563    // Compact form for the common [strong, em] case: ***text***.  em is
4564    // rendered with `*` here (as part of the `***` triple delimiter), so
4565    // underscores in the content don't need escaping.
4566    if marks.len() == 2 && marks[0].mark_type == "strong" && marks[1].mark_type == "em" {
4567        let escaped = escape_emphasis_markers(text);
4568        let escaped = escape_emoji_shortcodes(&escaped);
4569        let escaped = escape_backticks(&escaped);
4570        let escaped = escape_bare_urls(&escaped);
4571        output.push_str("***");
4572        output.push_str(&escaped);
4573        output.push_str("***");
4574        return;
4575    }
4576
4577    // When both strong and em are present (in any order), em uses `_` instead
4578    // of `*` to avoid the `***` triple-delimiter ambiguity.  Otherwise em uses
4579    // `*`, which sidesteps intraword-underscore pitfalls for plain em text.
4580    let em_delim = if has_strong && has_em { "_" } else { "*" };
4581
4582    // Text must also escape `_` when em renders as `_..._` — otherwise any
4583    // underscore in the content would close the emphasis span early.
4584    let escaped = if em_delim == "_" {
4585        escape_emphasis_markers_with_underscore(text)
4586    } else {
4587        escape_emphasis_markers(text)
4588    };
4589    let escaped = escape_emoji_shortcodes(&escaped);
4590    let escaped = escape_backticks(&escaped);
4591    // Always escape bare URLs so they are not re-parsed as `inlineCard`
4592    // nodes on round-trip.  When the text carries a link mark, also escape
4593    // `[` and `]` so they do not terminate the enclosing `[…]` link syntax
4594    // (issue #493).  Escaping bare URLs inside link text additionally
4595    // prevents `\[`/`\]` escapes from leaking through the URL-as-link-text
4596    // fast path and from corrupting an auto-detected bare URL inside the
4597    // link display text (issue #551).
4598    let escaped = escape_bare_urls(&escaped);
4599    let escaped = if has_link {
4600        escape_link_brackets(&escaped)
4601    } else {
4602        escaped
4603    };
4604
4605    // Collect (open, close) wrappers in mark order, outermost first.  Consecutive
4606    // span-attr or bracketed-span marks that happen to be in the parser's
4607    // canonical order (so the merged wrapper parses back to the same mark
4608    // sequence) are merged into a single wrapper; otherwise each mark gets its
4609    // own nested wrapper so that the mark ordering survives the round-trip.
4610    let mut wrappers: Vec<(String, String)> = Vec::new();
4611    let mut i = 0;
4612    while i < marks.len() {
4613        match marks[i].mark_type.as_str() {
4614            "em" => {
4615                wrappers.push((em_delim.to_string(), em_delim.to_string()));
4616                i += 1;
4617            }
4618            "strong" => {
4619                wrappers.push(("**".to_string(), "**".to_string()));
4620                i += 1;
4621            }
4622            "strike" => {
4623                wrappers.push(("~~".to_string(), "~~".to_string()));
4624                i += 1;
4625            }
4626            "link" => {
4627                let href = link_href(&marks[i]);
4628                wrappers.push(("[".to_string(), format!("]({href})")));
4629                i += 1;
4630            }
4631            "textColor" | "backgroundColor" | "subsup" => {
4632                let start = i;
4633                while i < marks.len() && is_span_attr_mark(&marks[i].mark_type) {
4634                    i += 1;
4635                }
4636                emit_span_attr_wrappers(&marks[start..i], &mut wrappers);
4637            }
4638            "underline" | "annotation" => {
4639                let start = i;
4640                while i < marks.len() && is_bracketed_span_mark(&marks[i].mark_type) {
4641                    i += 1;
4642                }
4643                emit_bracketed_wrappers(&marks[start..i], &mut wrappers);
4644            }
4645            _ => {
4646                i += 1;
4647            }
4648        }
4649    }
4650
4651    // Apply wrappers from innermost (last) to outermost (first).
4652    let mut result = escaped;
4653    for (open, close) in wrappers.iter().rev() {
4654        result.insert_str(0, open);
4655        result.push_str(close);
4656    }
4657    output.push_str(&result);
4658}
4659
4660/// Renders a text node with a `code` mark.  Code content is emitted verbatim
4661/// inside backticks, optionally wrapped by a link and/or by `:span`/bracketed-
4662/// span carrying span-attr (`textColor`, `backgroundColor`, `subsup`) and
4663/// bracketed-span (`underline`, `annotation`) marks.  No `em`/`strong`/`strike`
4664/// formatting is applied because markdown code spans do not support nested
4665/// emphasis (issue #554: previously textColor/bg/subsup/underline were
4666/// silently dropped when combined with a code mark).
4667fn render_code_marked_text(text: &str, marks: &[AdfMark], output: &mut String) {
4668    let link_mark = marks.iter().find(|m| m.mark_type == "link");
4669
4670    let mut code_str = String::new();
4671    if let Some(link_mark) = link_mark {
4672        let href = link_href(link_mark);
4673        code_str.push('[');
4674        render_inline_code(text, &mut code_str);
4675        code_str.push_str("](");
4676        code_str.push_str(href);
4677        code_str.push(')');
4678    } else {
4679        render_inline_code(text, &mut code_str);
4680    }
4681
4682    // Build wrappers (outermost first) for span-attr and bracketed-span runs,
4683    // walking marks in order so the round-trip preserves mark ordering.
4684    let mut wrappers: Vec<(String, String)> = Vec::new();
4685    let mut i = 0;
4686    while i < marks.len() {
4687        match marks[i].mark_type.as_str() {
4688            "textColor" | "backgroundColor" | "subsup" => {
4689                let start = i;
4690                while i < marks.len() && is_span_attr_mark(&marks[i].mark_type) {
4691                    i += 1;
4692                }
4693                emit_span_attr_wrappers(&marks[start..i], &mut wrappers);
4694            }
4695            "underline" | "annotation" => {
4696                let start = i;
4697                while i < marks.len() && is_bracketed_span_mark(&marks[i].mark_type) {
4698                    i += 1;
4699                }
4700                emit_bracketed_wrappers(&marks[start..i], &mut wrappers);
4701            }
4702            _ => {
4703                i += 1;
4704            }
4705        }
4706    }
4707
4708    // Apply wrappers from innermost (last) to outermost (first).
4709    let mut result = code_str;
4710    for (open, close) in wrappers.iter().rev() {
4711        result.insert_str(0, open);
4712        result.push_str(close);
4713    }
4714    output.push_str(&result);
4715}
4716
4717/// Collects `:span` attribute fragments (color, bg, sub/sup) for a single mark.
4718fn collect_span_attr(mark: &AdfMark, attrs: &mut Vec<String>) {
4719    match mark.mark_type.as_str() {
4720        "textColor" => {
4721            if let Some(c) = mark
4722                .attrs
4723                .as_ref()
4724                .and_then(|a| a.get("color"))
4725                .and_then(serde_json::Value::as_str)
4726            {
4727                attrs.push(format!("color={c}"));
4728            }
4729        }
4730        "backgroundColor" => {
4731            if let Some(c) = mark
4732                .attrs
4733                .as_ref()
4734                .and_then(|a| a.get("color"))
4735                .and_then(serde_json::Value::as_str)
4736            {
4737                attrs.push(format!("bg={c}"));
4738            }
4739        }
4740        "subsup" => {
4741            if let Some(kind) = mark
4742                .attrs
4743                .as_ref()
4744                .and_then(|a| a.get("type"))
4745                .and_then(serde_json::Value::as_str)
4746            {
4747                attrs.push(kind.to_string());
4748            }
4749        }
4750        _ => {}
4751    }
4752}
4753
4754/// Collects bracketed-span attribute fragments for an `underline` or `annotation` mark.
4755fn collect_bracketed_attr(mark: &AdfMark, attrs: &mut Vec<String>) {
4756    match mark.mark_type.as_str() {
4757        "underline" => attrs.push("underline".to_string()),
4758        "annotation" => {
4759            if let Some(ref a) = mark.attrs {
4760                if let Some(id) = a.get("id").and_then(serde_json::Value::as_str) {
4761                    let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4762                    attrs.push(format!("annotation-id=\"{escaped}\""));
4763                }
4764                if let Some(at) = a.get("annotationType").and_then(serde_json::Value::as_str) {
4765                    attrs.push(format!("annotation-type={at}"));
4766                }
4767            }
4768        }
4769        _ => {}
4770    }
4771}
4772
4773fn is_span_attr_mark(mark_type: &str) -> bool {
4774    matches!(mark_type, "textColor" | "backgroundColor" | "subsup")
4775}
4776
4777fn is_bracketed_span_mark(mark_type: &str) -> bool {
4778    matches!(mark_type, "underline" | "annotation")
4779}
4780
4781/// Canonical ordering for span-attr marks, matching the order in which the
4782/// `:span` directive parser reads attributes (`color`, then `bg`, then
4783/// `sub`/`sup`).
4784fn span_attr_order(mark_type: &str) -> u8 {
4785    match mark_type {
4786        "textColor" => 0,
4787        "backgroundColor" => 1,
4788        "subsup" => 2,
4789        _ => u8::MAX,
4790    }
4791}
4792
4793/// Returns `true` if the run of span-attr marks is in the canonical order the
4794/// `:span` parser would produce.  A canonical run can be merged into one
4795/// `:span[...]{...}` wrapper; a non-canonical run must be split into one
4796/// nested wrapper per mark so the ordering survives the round-trip.
4797fn span_run_is_canonical(run: &[AdfMark]) -> bool {
4798    let mut prev = 0;
4799    for m in run {
4800        let order = span_attr_order(&m.mark_type);
4801        if order == u8::MAX || order < prev {
4802            return false;
4803        }
4804        prev = order;
4805    }
4806    true
4807}
4808
4809/// Returns `true` if the run of `underline`/`annotation` marks is in the
4810/// canonical order the bracketed-span parser produces (`underline` first,
4811/// followed by annotations).  A canonical run can be merged into one
4812/// `[...]{underline annotation-id=...}` wrapper.
4813fn bracketed_run_is_canonical(run: &[AdfMark]) -> bool {
4814    let mut seen_annotation = false;
4815    for m in run {
4816        match m.mark_type.as_str() {
4817            "underline" => {
4818                if seen_annotation {
4819                    return false;
4820                }
4821            }
4822            "annotation" => seen_annotation = true,
4823            _ => return false,
4824        }
4825    }
4826    true
4827}
4828
4829/// Emits one or more `:span[...]{...}` wrappers for a run of span-attr marks.
4830/// Canonical-order runs collapse into a single wrapper; non-canonical runs
4831/// emit one wrapper per mark so the order round-trips.
4832fn emit_span_attr_wrappers(run: &[AdfMark], wrappers: &mut Vec<(String, String)>) {
4833    if span_run_is_canonical(run) {
4834        let mut attrs = Vec::new();
4835        for m in run {
4836            collect_span_attr(m, &mut attrs);
4837        }
4838        wrappers.push((":span[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4839        return;
4840    }
4841    for m in run {
4842        let mut attrs = Vec::new();
4843        collect_span_attr(m, &mut attrs);
4844        wrappers.push((":span[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4845    }
4846}
4847
4848/// Emits one or more `[...]{...}` wrappers for a run of `underline`/`annotation`
4849/// marks.  Canonical-order runs collapse into a single wrapper; non-canonical
4850/// runs emit one wrapper per mark so the order round-trips.
4851fn emit_bracketed_wrappers(run: &[AdfMark], wrappers: &mut Vec<(String, String)>) {
4852    if bracketed_run_is_canonical(run) {
4853        let mut attrs = Vec::new();
4854        for m in run {
4855            collect_bracketed_attr(m, &mut attrs);
4856        }
4857        wrappers.push(("[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4858        return;
4859    }
4860    for m in run {
4861        let mut attrs = Vec::new();
4862        collect_bracketed_attr(m, &mut attrs);
4863        wrappers.push(("[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4864    }
4865}
4866
4867/// Extracts the href from a link mark.
4868fn link_href(mark: &AdfMark) -> &str {
4869    mark.attrs
4870        .as_ref()
4871        .and_then(|a| a.get("href"))
4872        .and_then(serde_json::Value::as_str)
4873        .unwrap_or("")
4874}
4875
4876#[cfg(test)]
4877#[allow(
4878    clippy::unwrap_used,
4879    clippy::expect_used,
4880    clippy::needless_update,
4881    clippy::needless_collect,
4882    duplicate_macro_attributes
4883)]
4884mod tests {
4885    use super::*;
4886
4887    // ── adf_to_plain_text tests ─────────────────────────────────────
4888
4889    #[test]
4890    fn adf_to_plain_text_single_paragraph() {
4891        let doc = markdown_to_adf("Hello world").unwrap();
4892        assert_eq!(adf_to_plain_text(&doc), "Hello world");
4893    }
4894
4895    #[test]
4896    fn adf_to_plain_text_multiple_paragraphs_space_separated() {
4897        let doc = markdown_to_adf("Alpha\n\nBeta").unwrap();
4898        let plain = adf_to_plain_text(&doc);
4899        // Blocks are space-separated so multi-paragraph anchor selections match.
4900        assert!(plain.contains("Alpha"));
4901        assert!(plain.contains("Beta"));
4902        assert_eq!(plain, "Alpha Beta");
4903    }
4904
4905    #[test]
4906    fn adf_to_plain_text_drops_marks_but_keeps_text() {
4907        let doc = markdown_to_adf("Hello **bold** world").unwrap();
4908        assert_eq!(adf_to_plain_text(&doc), "Hello bold world");
4909    }
4910
4911    #[test]
4912    fn adf_to_plain_text_empty_doc() {
4913        let doc = AdfDocument::new();
4914        assert_eq!(adf_to_plain_text(&doc), "");
4915    }
4916
4917    #[test]
4918    fn adf_to_plain_text_leading_empty_block_emits_no_extra_space() {
4919        // An empty paragraph followed by a text-bearing one must not produce
4920        // a leading space — the separator logic skips when `out` is still empty.
4921        let doc = AdfDocument {
4922            version: 1,
4923            doc_type: "doc".to_string(),
4924            content: vec![
4925                AdfNode {
4926                    node_type: "paragraph".to_string(),
4927                    attrs: None,
4928                    content: Some(vec![]),
4929                    text: None,
4930                    marks: None,
4931                    local_id: None,
4932                    parameters: None,
4933                },
4934                AdfNode {
4935                    node_type: "paragraph".to_string(),
4936                    attrs: None,
4937                    content: Some(vec![AdfNode::text("Hello")]),
4938                    text: None,
4939                    marks: None,
4940                    local_id: None,
4941                    parameters: None,
4942                },
4943            ],
4944        };
4945        assert_eq!(adf_to_plain_text(&doc), "Hello");
4946    }
4947
4948    // ── markdown_to_adf tests ───────────────────────────────────────
4949
4950    #[test]
4951    fn paragraph() {
4952        let doc = markdown_to_adf("Hello world").unwrap();
4953        assert_eq!(doc.content.len(), 1);
4954        assert_eq!(doc.content[0].node_type, "paragraph");
4955    }
4956
4957    #[test]
4958    fn heading_levels() {
4959        for level in 1..=6 {
4960            let hashes = "#".repeat(level);
4961            let md = format!("{hashes} Title");
4962            let doc = markdown_to_adf(&md).unwrap();
4963            assert_eq!(doc.content[0].node_type, "heading");
4964            let attrs = doc.content[0].attrs.as_ref().unwrap();
4965            assert_eq!(attrs["level"], level as u64);
4966        }
4967    }
4968
4969    #[test]
4970    fn code_block() {
4971        let md = "```rust\nfn main() {}\n```";
4972        let doc = markdown_to_adf(md).unwrap();
4973        assert_eq!(doc.content[0].node_type, "codeBlock");
4974        let attrs = doc.content[0].attrs.as_ref().unwrap();
4975        assert_eq!(attrs["language"], "rust");
4976    }
4977
4978    #[test]
4979    fn code_block_no_language() {
4980        let md = "```\nsome code\n```";
4981        let doc = markdown_to_adf(md).unwrap();
4982        assert_eq!(doc.content[0].node_type, "codeBlock");
4983        assert!(doc.content[0].attrs.is_none());
4984    }
4985
4986    #[test]
4987    fn code_block_empty_language() {
4988        let md = "```\"\"\nsome code\n```";
4989        let doc = markdown_to_adf(md).unwrap();
4990        assert_eq!(doc.content[0].node_type, "codeBlock");
4991        let attrs = doc.content[0].attrs.as_ref().unwrap();
4992        assert_eq!(attrs["language"], "");
4993    }
4994
4995    #[test]
4996    fn horizontal_rule() {
4997        let doc = markdown_to_adf("---").unwrap();
4998        assert_eq!(doc.content[0].node_type, "rule");
4999    }
5000
5001    #[test]
5002    fn horizontal_rule_stars() {
5003        let doc = markdown_to_adf("***").unwrap();
5004        assert_eq!(doc.content[0].node_type, "rule");
5005    }
5006
5007    #[test]
5008    fn blockquote() {
5009        let md = "> This is a quote\n> Second line";
5010        let doc = markdown_to_adf(md).unwrap();
5011        assert_eq!(doc.content[0].node_type, "blockquote");
5012    }
5013
5014    #[test]
5015    fn bullet_list() {
5016        let md = "- Item 1\n- Item 2\n- Item 3";
5017        let doc = markdown_to_adf(md).unwrap();
5018        assert_eq!(doc.content[0].node_type, "bulletList");
5019        let items = doc.content[0].content.as_ref().unwrap();
5020        assert_eq!(items.len(), 3);
5021    }
5022
5023    #[test]
5024    fn ordered_list() {
5025        let md = "1. First\n2. Second\n3. Third";
5026        let doc = markdown_to_adf(md).unwrap();
5027        assert_eq!(doc.content[0].node_type, "orderedList");
5028        let items = doc.content[0].content.as_ref().unwrap();
5029        assert_eq!(items.len(), 3);
5030    }
5031
5032    #[test]
5033    fn task_list() {
5034        let md = "- [ ] Todo item\n- [x] Done item";
5035        let doc = markdown_to_adf(md).unwrap();
5036        assert_eq!(doc.content[0].node_type, "taskList");
5037        let items = doc.content[0].content.as_ref().unwrap();
5038        assert_eq!(items.len(), 2);
5039        assert_eq!(items[0].node_type, "taskItem");
5040        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5041        assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5042    }
5043
5044    #[test]
5045    fn task_list_uppercase_x() {
5046        let md = "- [X] Done item";
5047        let doc = markdown_to_adf(md).unwrap();
5048        assert_eq!(doc.content[0].node_type, "taskList");
5049        let item = &doc.content[0].content.as_ref().unwrap()[0];
5050        assert_eq!(item.attrs.as_ref().unwrap()["state"], "DONE");
5051    }
5052
5053    /// Issue #548: an empty task marker (no trailing space) must still be
5054    /// parsed as a `taskList` rather than a `bulletList` with `[ ]` text.
5055    #[test]
5056    fn task_list_empty_todo_no_trailing_space() {
5057        let md = "- [ ]";
5058        let doc = markdown_to_adf(md).unwrap();
5059        assert_eq!(doc.content[0].node_type, "taskList");
5060        let items = doc.content[0].content.as_ref().unwrap();
5061        assert_eq!(items.len(), 1);
5062        assert_eq!(items[0].node_type, "taskItem");
5063        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5064        assert!(items[0].content.is_none());
5065    }
5066
5067    /// Issue #548: likewise for a done checkbox with no body.
5068    #[test]
5069    fn task_list_empty_done_no_trailing_space() {
5070        let md = "- [x]\n- [X]";
5071        let doc = markdown_to_adf(md).unwrap();
5072        assert_eq!(doc.content[0].node_type, "taskList");
5073        let items = doc.content[0].content.as_ref().unwrap();
5074        assert_eq!(items.len(), 2);
5075        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DONE");
5076        assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5077    }
5078
5079    /// Issue #548: the body of `- [ ] text` must not have a spurious leading
5080    /// space introduced by relaxing the trailing-space requirement.
5081    #[test]
5082    fn task_list_body_has_no_leading_space() {
5083        let md = "- [ ] Buy groceries";
5084        let doc = markdown_to_adf(md).unwrap();
5085        let item = &doc.content[0].content.as_ref().unwrap()[0];
5086        let text = item.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5087        assert_eq!(text, "Buy groceries");
5088    }
5089
5090    /// Issue #548: round-trip from ADF with empty taskItems should preserve
5091    /// the `taskList` structure even if trailing spaces are stripped from the
5092    /// intermediate markdown (as many editors do).
5093    #[test]
5094    fn round_trip_empty_task_items_stripped_trailing_spaces() {
5095        let json = r#"{
5096            "version": 1,
5097            "type": "doc",
5098            "content": [{
5099                "type": "taskList",
5100                "attrs": {"localId": "abc"},
5101                "content": [
5102                    {"type": "taskItem", "attrs": {"localId": "def", "state": "TODO"}},
5103                    {"type": "taskItem", "attrs": {"localId": "ghi", "state": "DONE"}}
5104                ]
5105            }]
5106        }"#;
5107        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5108        let md = adf_to_markdown(&doc).unwrap();
5109        let stripped: String = md.lines().map(str::trim_end).collect::<Vec<_>>().join("\n");
5110        let parsed = markdown_to_adf(&stripped).unwrap();
5111        assert_eq!(parsed.content[0].node_type, "taskList");
5112        let items = parsed.content[0].content.as_ref().unwrap();
5113        assert_eq!(items.len(), 2);
5114        assert_eq!(items[0].node_type, "taskItem");
5115        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5116        assert_eq!(items[1].node_type, "taskItem");
5117        assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5118    }
5119
5120    #[test]
5121    fn try_parse_task_marker_accepts_bare_checkbox() {
5122        assert_eq!(try_parse_task_marker("[ ]"), Some(("TODO", "")));
5123        assert_eq!(try_parse_task_marker("[x]"), Some(("DONE", "")));
5124        assert_eq!(try_parse_task_marker("[X]"), Some(("DONE", "")));
5125        assert_eq!(try_parse_task_marker("[ ] foo"), Some(("TODO", "foo")));
5126        assert_eq!(try_parse_task_marker("[x] foo"), Some(("DONE", "foo")));
5127        assert_eq!(try_parse_task_marker("[ ]foo"), None);
5128        assert_eq!(try_parse_task_marker("[x]foo"), None);
5129        assert_eq!(try_parse_task_marker("[y] foo"), None);
5130    }
5131
5132    #[test]
5133    fn starts_with_task_marker_matches_parser() {
5134        // Anything `try_parse_task_marker` recognises must also be flagged
5135        // here so the renderer escapes it.
5136        assert!(starts_with_task_marker("[ ]"));
5137        assert!(starts_with_task_marker("[x]"));
5138        assert!(starts_with_task_marker("[X]"));
5139        assert!(starts_with_task_marker("[ ] foo"));
5140        assert!(starts_with_task_marker("[x] foo\n"));
5141        assert!(starts_with_task_marker("[ ]\n"));
5142        // No collision when the bracket is followed by non-whitespace.
5143        assert!(!starts_with_task_marker("[ ]foo"));
5144        assert!(!starts_with_task_marker("[y] foo"));
5145        assert!(!starts_with_task_marker("foo [ ] bar"));
5146        assert!(!starts_with_task_marker(""));
5147    }
5148
5149    /// Issue #548: a `bulletList` whose item starts with literal `[ ]` text
5150    /// must round-trip through markdown without being promoted to a
5151    /// `taskList`.
5152    #[test]
5153    fn round_trip_bullet_list_with_literal_checkbox_text() {
5154        let json = r#"{
5155            "version": 1,
5156            "type": "doc",
5157            "content": [{
5158                "type": "bulletList",
5159                "content": [{
5160                    "type": "listItem",
5161                    "content": [{
5162                        "type": "paragraph",
5163                        "content": [
5164                            {"type": "text", "text": "[ ] Review the "},
5165                            {"type": "text", "text": "config.yaml", "marks": [{"type": "code"}]},
5166                            {"type": "text", "text": " file"}
5167                        ]
5168                    }]
5169                }]
5170            }]
5171        }"#;
5172        let original: AdfDocument = serde_json::from_str(json).unwrap();
5173        let md = adf_to_markdown(&original).unwrap();
5174        // Renderer must escape the leading bracket.
5175        assert!(
5176            md.contains(r"- \[ ] Review the "),
5177            "rendered markdown: {md:?}"
5178        );
5179        let parsed = markdown_to_adf(&md).unwrap();
5180        assert_eq!(parsed.content[0].node_type, "bulletList");
5181        let item = &parsed.content[0].content.as_ref().unwrap()[0];
5182        assert_eq!(item.node_type, "listItem");
5183        let para = &item.content.as_ref().unwrap()[0];
5184        assert_eq!(para.node_type, "paragraph");
5185        let text_nodes = para.content.as_ref().unwrap();
5186        assert_eq!(text_nodes[0].text.as_deref().unwrap(), "[ ] Review the ");
5187        assert_eq!(text_nodes[1].text.as_deref().unwrap(), "config.yaml");
5188        assert_eq!(text_nodes[2].text.as_deref().unwrap(), " file");
5189    }
5190
5191    /// Issue #548: the same problem with a `[x]` marker.
5192    #[test]
5193    fn round_trip_bullet_list_with_literal_done_checkbox_text() {
5194        let json = r#"{
5195            "version": 1,
5196            "type": "doc",
5197            "content": [{
5198                "type": "bulletList",
5199                "content": [{
5200                    "type": "listItem",
5201                    "content": [{
5202                        "type": "paragraph",
5203                        "content": [{"type": "text", "text": "[x] not actually done"}]
5204                    }]
5205                }]
5206            }]
5207        }"#;
5208        let original: AdfDocument = serde_json::from_str(json).unwrap();
5209        let md = adf_to_markdown(&original).unwrap();
5210        assert!(md.contains(r"- \[x] "), "rendered markdown: {md:?}");
5211        let parsed = markdown_to_adf(&md).unwrap();
5212        assert_eq!(parsed.content[0].node_type, "bulletList");
5213        let item = &parsed.content[0].content.as_ref().unwrap()[0];
5214        let para = &item.content.as_ref().unwrap()[0];
5215        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5216        assert_eq!(text, "[x] not actually done");
5217    }
5218
5219    /// Issue #548: `bulletList` item whose entire content is literal `[ ]`.
5220    #[test]
5221    fn round_trip_bullet_list_with_bare_literal_checkbox() {
5222        let json = r#"{
5223            "version": 1,
5224            "type": "doc",
5225            "content": [{
5226                "type": "bulletList",
5227                "content": [{
5228                    "type": "listItem",
5229                    "content": [{
5230                        "type": "paragraph",
5231                        "content": [{"type": "text", "text": "[ ]"}]
5232                    }]
5233                }]
5234            }]
5235        }"#;
5236        let original: AdfDocument = serde_json::from_str(json).unwrap();
5237        let md = adf_to_markdown(&original).unwrap();
5238        let parsed = markdown_to_adf(&md).unwrap();
5239        assert_eq!(parsed.content[0].node_type, "bulletList");
5240        let item = &parsed.content[0].content.as_ref().unwrap()[0];
5241        let para = &item.content.as_ref().unwrap()[0];
5242        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5243        assert_eq!(text, "[ ]");
5244    }
5245
5246    /// Issue #548: a `bulletList` with a non-task `[?]` prefix should not be
5247    /// escaped — that would just produce noise.
5248    #[test]
5249    fn bullet_list_non_task_bracket_text_not_escaped() {
5250        let json = r#"{
5251            "version": 1,
5252            "type": "doc",
5253            "content": [{
5254                "type": "bulletList",
5255                "content": [{
5256                    "type": "listItem",
5257                    "content": [{
5258                        "type": "paragraph",
5259                        "content": [{"type": "text", "text": "[?] unsure"}]
5260                    }]
5261                }]
5262            }]
5263        }"#;
5264        let original: AdfDocument = serde_json::from_str(json).unwrap();
5265        let md = adf_to_markdown(&original).unwrap();
5266        assert!(!md.contains(r"\["), "should not escape: {md:?}");
5267        assert!(md.contains("- [?] unsure"), "rendered: {md:?}");
5268    }
5269
5270    /// Issue #548: nested `bulletList` items inside another `bulletList`
5271    /// must also have their literal `[ ]` text escaped.
5272    #[test]
5273    fn round_trip_nested_bullet_list_with_literal_checkbox_text() {
5274        let json = r#"{
5275            "version": 1,
5276            "type": "doc",
5277            "content": [{
5278                "type": "bulletList",
5279                "content": [{
5280                    "type": "listItem",
5281                    "content": [
5282                        {"type": "paragraph", "content": [{"type": "text", "text": "outer"}]},
5283                        {"type": "bulletList", "content": [{
5284                            "type": "listItem",
5285                            "content": [{
5286                                "type": "paragraph",
5287                                "content": [{"type": "text", "text": "[ ] inner literal"}]
5288                            }]
5289                        }]}
5290                    ]
5291                }]
5292            }]
5293        }"#;
5294        let original: AdfDocument = serde_json::from_str(json).unwrap();
5295        let md = adf_to_markdown(&original).unwrap();
5296        let parsed = markdown_to_adf(&md).unwrap();
5297        let outer = &parsed.content[0];
5298        assert_eq!(outer.node_type, "bulletList");
5299        let outer_item = &outer.content.as_ref().unwrap()[0];
5300        let inner_list = &outer_item.content.as_ref().unwrap()[1];
5301        assert_eq!(inner_list.node_type, "bulletList");
5302        let inner_item = &inner_list.content.as_ref().unwrap()[0];
5303        assert_eq!(inner_item.node_type, "listItem");
5304        let para = &inner_item.content.as_ref().unwrap()[0];
5305        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5306        assert_eq!(text, "[ ] inner literal");
5307    }
5308
5309    #[test]
5310    fn adf_task_list_to_markdown() {
5311        let doc = AdfDocument {
5312            version: 1,
5313            doc_type: "doc".to_string(),
5314            content: vec![AdfNode::task_list(vec![
5315                AdfNode::task_item(
5316                    "TODO",
5317                    vec![AdfNode::paragraph(vec![AdfNode::text("Todo")])],
5318                ),
5319                AdfNode::task_item(
5320                    "DONE",
5321                    vec![AdfNode::paragraph(vec![AdfNode::text("Done")])],
5322                ),
5323            ])],
5324        };
5325        let md = adf_to_markdown(&doc).unwrap();
5326        assert!(md.contains("- [ ] Todo"));
5327        assert!(md.contains("- [x] Done"));
5328    }
5329
5330    #[test]
5331    fn round_trip_task_list() {
5332        let md = "- [ ] Todo item\n- [x] Done item\n";
5333        let doc = markdown_to_adf(md).unwrap();
5334        let result = adf_to_markdown(&doc).unwrap();
5335        assert!(result.contains("- [ ] Todo item"));
5336        assert!(result.contains("- [x] Done item"));
5337    }
5338
5339    /// Issue #408: taskItem content with inline nodes directly (no paragraph wrapper).
5340    #[test]
5341    fn adf_task_item_unwrapped_inline_content() {
5342        // Real Confluence ADF: taskItem contains text nodes directly, no paragraph.
5343        let json = r#"{
5344            "version": 1,
5345            "type": "doc",
5346            "content": [{
5347                "type": "taskList",
5348                "attrs": {"localId": "list-001"},
5349                "content": [{
5350                    "type": "taskItem",
5351                    "attrs": {"localId": "task-001", "state": "TODO"},
5352                    "content": [{"type": "text", "text": "Do something"}]
5353                }]
5354            }]
5355        }"#;
5356        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5357        let md = adf_to_markdown(&doc).unwrap();
5358        assert!(md.contains("- [ ] Do something"), "got: {md}");
5359        assert!(!md.contains("adf-unsupported"), "got: {md}");
5360    }
5361
5362    /// Issue #408: multiple taskItems with unwrapped inline content.
5363    #[test]
5364    fn adf_task_list_multiple_unwrapped_items() {
5365        let json = r#"{
5366            "version": 1,
5367            "type": "doc",
5368            "content": [{
5369                "type": "taskList",
5370                "attrs": {"localId": "list-001"},
5371                "content": [
5372                    {
5373                        "type": "taskItem",
5374                        "attrs": {"localId": "task-001", "state": "TODO"},
5375                        "content": [{"type": "text", "text": "First task"}]
5376                    },
5377                    {
5378                        "type": "taskItem",
5379                        "attrs": {"localId": "task-002", "state": "DONE"},
5380                        "content": [{"type": "text", "text": "Second task"}]
5381                    }
5382                ]
5383            }]
5384        }"#;
5385        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5386        let md = adf_to_markdown(&doc).unwrap();
5387        assert!(md.contains("- [ ] First task"), "got: {md}");
5388        assert!(md.contains("- [x] Second task"), "got: {md}");
5389        assert!(!md.contains("adf-unsupported"), "got: {md}");
5390    }
5391
5392    /// Issue #408: unwrapped inline content with marks (bold text).
5393    #[test]
5394    fn adf_task_item_unwrapped_inline_with_marks() {
5395        let json = r#"{
5396            "version": 1,
5397            "type": "doc",
5398            "content": [{
5399                "type": "taskList",
5400                "attrs": {"localId": "list-001"},
5401                "content": [{
5402                    "type": "taskItem",
5403                    "attrs": {"localId": "task-001", "state": "TODO"},
5404                    "content": [
5405                        {"type": "text", "text": "Buy "},
5406                        {"type": "text", "text": "groceries", "marks": [{"type": "strong"}]},
5407                        {"type": "text", "text": " today"}
5408                    ]
5409                }]
5410            }]
5411        }"#;
5412        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5413        let md = adf_to_markdown(&doc).unwrap();
5414        assert!(md.contains("- [ ] Buy **groceries** today"), "got: {md}");
5415    }
5416
5417    /// Issue #408: taskItem localId is preserved for unwrapped inline content.
5418    #[test]
5419    fn adf_task_item_unwrapped_preserves_local_id() {
5420        let json = r#"{
5421            "version": 1,
5422            "type": "doc",
5423            "content": [{
5424                "type": "taskList",
5425                "attrs": {"localId": "list-001"},
5426                "content": [{
5427                    "type": "taskItem",
5428                    "attrs": {"localId": "task-001", "state": "TODO"},
5429                    "content": [{"type": "text", "text": "Do something"}]
5430                }]
5431            }]
5432        }"#;
5433        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5434        let md = adf_to_markdown(&doc).unwrap();
5435        assert!(md.contains("{localId=task-001}"), "got: {md}");
5436        assert!(md.contains("{localId=list-001}"), "got: {md}");
5437    }
5438
5439    /// Issue #408: round-trip from Confluence ADF with unwrapped taskItem content.
5440    #[test]
5441    fn round_trip_task_list_unwrapped_inline() {
5442        let json = r#"{
5443            "version": 1,
5444            "type": "doc",
5445            "content": [{
5446                "type": "taskList",
5447                "attrs": {"localId": "list-001"},
5448                "content": [
5449                    {
5450                        "type": "taskItem",
5451                        "attrs": {"localId": "task-001", "state": "TODO"},
5452                        "content": [{"type": "text", "text": "Do something"}]
5453                    },
5454                    {
5455                        "type": "taskItem",
5456                        "attrs": {"localId": "task-002", "state": "DONE"},
5457                        "content": [{"type": "text", "text": "Already done"}]
5458                    }
5459                ]
5460            }]
5461        }"#;
5462        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5463        let md = adf_to_markdown(&doc).unwrap();
5464
5465        // Round-trip: markdown back to ADF
5466        let doc2 = markdown_to_adf(&md).unwrap();
5467        assert_eq!(doc2.content[0].node_type, "taskList");
5468
5469        let items = doc2.content[0].content.as_ref().unwrap();
5470        assert_eq!(items.len(), 2);
5471        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5472        assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5473
5474        // localIds preserved
5475        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "task-001");
5476        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "task-002");
5477        assert_eq!(
5478            doc2.content[0].attrs.as_ref().unwrap()["localId"],
5479            "list-001"
5480        );
5481    }
5482
5483    /// Issue #408: taskItem with inline content followed by a nested block (sub-list).
5484    #[test]
5485    fn adf_task_item_unwrapped_inline_then_block() {
5486        let json = r#"{
5487            "version": 1,
5488            "type": "doc",
5489            "content": [{
5490                "type": "taskList",
5491                "attrs": {"localId": "list-001"},
5492                "content": [{
5493                    "type": "taskItem",
5494                    "attrs": {"localId": "task-001", "state": "TODO"},
5495                    "content": [
5496                        {"type": "text", "text": "Parent task"},
5497                        {
5498                            "type": "bulletList",
5499                            "content": [{
5500                                "type": "listItem",
5501                                "content": [{
5502                                    "type": "paragraph",
5503                                    "content": [{"type": "text", "text": "sub-item"}]
5504                                }]
5505                            }]
5506                        }
5507                    ]
5508                }]
5509            }]
5510        }"#;
5511        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5512        let md = adf_to_markdown(&doc).unwrap();
5513        assert!(md.contains("- [ ] Parent task"), "got: {md}");
5514        assert!(md.contains("  - sub-item"), "got: {md}");
5515        assert!(!md.contains("adf-unsupported"), "got: {md}");
5516    }
5517
5518    /// Issue #408: taskItem with empty content array renders without panic.
5519    #[test]
5520    fn adf_task_item_empty_content() {
5521        let json = r#"{
5522            "version": 1,
5523            "type": "doc",
5524            "content": [{
5525                "type": "taskList",
5526                "attrs": {"localId": "list-001"},
5527                "content": [{
5528                    "type": "taskItem",
5529                    "attrs": {"localId": "task-001", "state": "TODO"},
5530                    "content": []
5531                }]
5532            }]
5533        }"#;
5534        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5535        let md = adf_to_markdown(&doc).unwrap();
5536        assert!(md.contains("- [ ] "), "got: {md}");
5537        assert!(!md.contains("adf-unsupported"), "got: {md}");
5538    }
5539
5540    /// Issue #489: nested taskItem inside taskItem.content renders as indented
5541    /// task items instead of corrupting the surrounding taskList.
5542    #[test]
5543    fn adf_nested_task_item_renders_without_corruption() {
5544        let json = r#"{
5545            "type": "doc",
5546            "version": 1,
5547            "content": [{
5548                "type": "taskList",
5549                "attrs": {"localId": ""},
5550                "content": [
5551                    {
5552                        "type": "taskItem",
5553                        "attrs": {"localId": "aabbccdd-1234-5678-abcd-aabbccdd1234", "state": "TODO"},
5554                        "content": [{"type": "text", "text": "Normal task"}]
5555                    },
5556                    {
5557                        "type": "taskItem",
5558                        "attrs": {"localId": ""},
5559                        "content": [
5560                            {
5561                                "type": "taskItem",
5562                                "attrs": {"localId": "bbccddee-2345-6789-bcde-bbccddee2345", "state": "TODO"},
5563                                "content": [{"type": "text", "text": "Nested task one"}]
5564                            },
5565                            {
5566                                "type": "taskItem",
5567                                "attrs": {"localId": "ccddee11-3456-7890-cdef-ccddee113456", "state": "DONE"},
5568                                "content": [{"type": "text", "text": "Nested task two"}]
5569                            }
5570                        ]
5571                    }
5572                ]
5573            }]
5574        }"#;
5575        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5576        let md = adf_to_markdown(&doc).unwrap();
5577        // Normal task preserved
5578        assert!(md.contains("- [ ] Normal task"), "got: {md}");
5579        // Nested tasks rendered as indented task items, not adf-unsupported
5580        assert!(!md.contains("adf-unsupported"), "got: {md}");
5581        assert!(md.contains("  - [ ] Nested task one"), "got: {md}");
5582        assert!(md.contains("  - [x] Nested task two"), "got: {md}");
5583    }
5584
5585    /// Issue #489: round-trip of nested taskItem preserves data.
5586    #[test]
5587    fn round_trip_nested_task_item() {
5588        let json = r#"{
5589            "type": "doc",
5590            "version": 1,
5591            "content": [{
5592                "type": "taskList",
5593                "attrs": {"localId": ""},
5594                "content": [
5595                    {
5596                        "type": "taskItem",
5597                        "attrs": {"localId": "task-001", "state": "TODO"},
5598                        "content": [{"type": "text", "text": "Normal task"}]
5599                    },
5600                    {
5601                        "type": "taskItem",
5602                        "attrs": {"localId": ""},
5603                        "content": [
5604                            {
5605                                "type": "taskItem",
5606                                "attrs": {"localId": "task-002", "state": "TODO"},
5607                                "content": [{"type": "text", "text": "Nested one"}]
5608                            },
5609                            {
5610                                "type": "taskItem",
5611                                "attrs": {"localId": "task-003", "state": "DONE"},
5612                                "content": [{"type": "text", "text": "Nested two"}]
5613                            }
5614                        ]
5615                    }
5616                ]
5617            }]
5618        }"#;
5619        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5620        let md = adf_to_markdown(&doc).unwrap();
5621        let doc2 = markdown_to_adf(&md).unwrap();
5622
5623        // Top-level structure: taskList with 2 items
5624        assert_eq!(doc2.content[0].node_type, "taskList");
5625        let items = doc2.content[0].content.as_ref().unwrap();
5626        assert_eq!(items.len(), 2, "expected 2 top-level items, got: {items:?}");
5627
5628        // First item: normal task preserved
5629        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5630        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "task-001");
5631        let first_content = items[0].content.as_ref().unwrap();
5632        assert_eq!(first_content[0].text.as_deref(), Some("Normal task"));
5633
5634        // Second item: container taskItem — no spurious `state` attr
5635        let container = &items[1];
5636        assert_eq!(container.node_type, "taskItem");
5637        let c_attrs = container.attrs.as_ref().unwrap();
5638        assert!(
5639            c_attrs.get("state").is_none(),
5640            "container should have no state attr, got: {c_attrs:?}"
5641        );
5642
5643        // Children are bare taskItems, NOT wrapped in a taskList
5644        let container_content = container.content.as_ref().unwrap();
5645        assert_eq!(
5646            container_content.len(),
5647            2,
5648            "expected 2 bare taskItem children"
5649        );
5650        assert_eq!(container_content[0].node_type, "taskItem");
5651        assert_eq!(
5652            container_content[0].attrs.as_ref().unwrap()["state"],
5653            "TODO"
5654        );
5655        assert_eq!(
5656            container_content[0].attrs.as_ref().unwrap()["localId"],
5657            "task-002"
5658        );
5659        assert_eq!(container_content[1].node_type, "taskItem");
5660        assert_eq!(
5661            container_content[1].attrs.as_ref().unwrap()["state"],
5662            "DONE"
5663        );
5664        assert_eq!(
5665            container_content[1].attrs.as_ref().unwrap()["localId"],
5666            "task-003"
5667        );
5668    }
5669
5670    /// Issue #489: nested taskItem with localIds on both container and children.
5671    #[test]
5672    fn adf_nested_task_item_preserves_local_ids() {
5673        let json = r#"{
5674            "type": "doc",
5675            "version": 1,
5676            "content": [{
5677                "type": "taskList",
5678                "attrs": {"localId": "list-001"},
5679                "content": [{
5680                    "type": "taskItem",
5681                    "attrs": {"localId": "container-001", "state": "TODO"},
5682                    "content": [{
5683                        "type": "taskItem",
5684                        "attrs": {"localId": "child-001", "state": "DONE"},
5685                        "content": [{"type": "text", "text": "Nested child"}]
5686                    }]
5687                }]
5688            }]
5689        }"#;
5690        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5691        let md = adf_to_markdown(&doc).unwrap();
5692        // Container localId is emitted
5693        assert!(
5694            md.contains("localId=container-001"),
5695            "container localId missing: {md}"
5696        );
5697        // Child localId is emitted
5698        assert!(
5699            md.contains("localId=child-001"),
5700            "child localId missing: {md}"
5701        );
5702        assert!(!md.contains("adf-unsupported"), "got: {md}");
5703    }
5704
5705    /// Issue #489: nested taskItem content mixed with a non-taskItem block node.
5706    /// Covers the else branch in the renderer where a child is not a taskItem.
5707    #[test]
5708    fn adf_nested_task_item_mixed_with_block_node() {
5709        let json = r#"{
5710            "type": "doc",
5711            "version": 1,
5712            "content": [{
5713                "type": "taskList",
5714                "attrs": {"localId": ""},
5715                "content": [{
5716                    "type": "taskItem",
5717                    "attrs": {"localId": "", "state": "TODO"},
5718                    "content": [
5719                        {
5720                            "type": "taskItem",
5721                            "attrs": {"localId": "", "state": "TODO"},
5722                            "content": [{"type": "text", "text": "A nested task"}]
5723                        },
5724                        {
5725                            "type": "paragraph",
5726                            "content": [{"type": "text", "text": "Stray paragraph"}]
5727                        }
5728                    ]
5729                }]
5730            }]
5731        }"#;
5732        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5733        let md = adf_to_markdown(&doc).unwrap();
5734        assert!(md.contains("  - [ ] A nested task"), "got: {md}");
5735        assert!(md.contains("  Stray paragraph"), "got: {md}");
5736        assert!(!md.contains("adf-unsupported"), "got: {md}");
5737    }
5738
5739    /// Issue #489: task item with inline text AND indented sub-content.
5740    /// Covers the parser's `Some` branch when appending nested blocks to
5741    /// an existing content vec.
5742    #[test]
5743    fn task_item_with_text_and_nested_sub_content() {
5744        let md = "- [ ] Parent task\n  - [ ] Sub task\n";
5745        let doc = markdown_to_adf(md).unwrap();
5746        assert_eq!(doc.content[0].node_type, "taskList");
5747        let items = doc.content[0].content.as_ref().unwrap();
5748        // Issue #506: the nested taskList is a sibling of the taskItem,
5749        // not a child — matching ADF's canonical structure.
5750        assert_eq!(items.len(), 2, "got: {items:?}");
5751        let parent = &items[0];
5752        assert_eq!(parent.attrs.as_ref().unwrap()["state"], "TODO");
5753        let parent_content = parent.content.as_ref().unwrap();
5754        assert_eq!(parent_content[0].text.as_deref(), Some("Parent task"));
5755        // Second item: nested taskList (sibling)
5756        assert_eq!(items[1].node_type, "taskList");
5757        let nested = items[1].content.as_ref().unwrap();
5758        assert_eq!(nested.len(), 1);
5759        assert_eq!(nested[0].attrs.as_ref().unwrap()["state"], "TODO");
5760    }
5761
5762    /// Issue #489: empty task item with non-taskList sub-content (e.g. a
5763    /// paragraph).  Exercises the `None` branch when the sub-content does
5764    /// not qualify for container-unwrap.
5765    #[test]
5766    fn task_item_empty_with_non_tasklist_sub_content() {
5767        let md = "- [ ] \n  Some paragraph text\n";
5768        let doc = markdown_to_adf(md).unwrap();
5769        assert_eq!(doc.content[0].node_type, "taskList");
5770        let items = doc.content[0].content.as_ref().unwrap();
5771        assert_eq!(items.len(), 1);
5772        let item = &items[0];
5773        assert_eq!(item.attrs.as_ref().unwrap()["state"], "TODO");
5774        let content = item.content.as_ref().unwrap();
5775        // Sub-content is a paragraph (not unwrapped since it's not a taskList)
5776        assert_eq!(content[0].node_type, "paragraph");
5777    }
5778
5779    /// Issue #489: single nested taskItem (edge case — only one child).
5780    #[test]
5781    fn adf_nested_task_item_single_child() {
5782        let json = r#"{
5783            "type": "doc",
5784            "version": 1,
5785            "content": [{
5786                "type": "taskList",
5787                "attrs": {"localId": ""},
5788                "content": [{
5789                    "type": "taskItem",
5790                    "attrs": {"localId": "", "state": "TODO"},
5791                    "content": [{
5792                        "type": "taskItem",
5793                        "attrs": {"localId": "", "state": "DONE"},
5794                        "content": [{"type": "text", "text": "Only child"}]
5795                    }]
5796                }]
5797            }]
5798        }"#;
5799        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5800        let md = adf_to_markdown(&doc).unwrap();
5801        assert!(md.contains("  - [x] Only child"), "got: {md}");
5802        assert!(!md.contains("adf-unsupported"), "got: {md}");
5803    }
5804
5805    /// Issue #506: nested taskList as direct child of outer taskList is
5806    /// rendered indented so it round-trips back as taskList, not taskItem.
5807    #[test]
5808    fn adf_nested_tasklist_sibling_renders_indented() {
5809        let json = r#"{
5810            "version": 1,
5811            "type": "doc",
5812            "content": [{
5813                "type": "taskList",
5814                "attrs": {"localId": ""},
5815                "content": [
5816                    {
5817                        "type": "taskItem",
5818                        "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000001", "state": "TODO"},
5819                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task one"}]}]
5820                    },
5821                    {
5822                        "type": "taskList",
5823                        "attrs": {"localId": ""},
5824                        "content": [{
5825                            "type": "taskItem",
5826                            "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000002", "state": "TODO"},
5827                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "nested sub-task"}]}]
5828                        }]
5829                    },
5830                    {
5831                        "type": "taskItem",
5832                        "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000003", "state": "TODO"},
5833                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task two"}]}]
5834                    }
5835                ]
5836            }]
5837        }"#;
5838        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5839        let md = adf_to_markdown(&doc).unwrap();
5840        // The nested taskList should be indented under the preceding item.
5841        assert!(md.contains("- [ ] parent task one"), "got: {md}");
5842        assert!(md.contains("  - [ ] nested sub-task"), "got: {md}");
5843        assert!(md.contains("- [ ] parent task two"), "got: {md}");
5844    }
5845
5846    /// Issue #506: round-trip preserves nested taskList type.
5847    #[test]
5848    fn round_trip_nested_tasklist_preserves_type() {
5849        let json = r#"{
5850            "version": 1,
5851            "type": "doc",
5852            "content": [{
5853                "type": "taskList",
5854                "attrs": {"localId": ""},
5855                "content": [
5856                    {
5857                        "type": "taskItem",
5858                        "attrs": {"localId": "", "state": "TODO"},
5859                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task one"}]}]
5860                    },
5861                    {
5862                        "type": "taskList",
5863                        "attrs": {"localId": ""},
5864                        "content": [{
5865                            "type": "taskItem",
5866                            "attrs": {"localId": "", "state": "TODO"},
5867                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "nested sub-task"}]}]
5868                        }]
5869                    },
5870                    {
5871                        "type": "taskItem",
5872                        "attrs": {"localId": "", "state": "TODO"},
5873                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task two"}]}]
5874                    }
5875                ]
5876            }]
5877        }"#;
5878        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5879        let md = adf_to_markdown(&doc).unwrap();
5880        let rt_doc = markdown_to_adf(&md).unwrap();
5881        // The outer taskList should still be present.
5882        assert_eq!(rt_doc.content[0].node_type, "taskList");
5883        let items = rt_doc.content[0].content.as_ref().unwrap();
5884        // The nested taskList is a sibling of the taskItem nodes,
5885        // matching the original ADF structure (issue #506).
5886        assert_eq!(items.len(), 3, "got: {items:?}");
5887        assert_eq!(items[0].node_type, "taskItem");
5888        assert_eq!(
5889            items[1].node_type, "taskList",
5890            "nested taskList should survive round-trip"
5891        );
5892        assert_eq!(items[2].node_type, "taskItem");
5893        let nested_items = items[1].content.as_ref().unwrap();
5894        assert_eq!(nested_items[0].attrs.as_ref().unwrap()["state"], "TODO");
5895    }
5896
5897    /// Issue #506: nested taskList with DONE state preserves checkbox.
5898    #[test]
5899    fn adf_nested_tasklist_done_state() {
5900        let json = r#"{
5901            "version": 1,
5902            "type": "doc",
5903            "content": [{
5904                "type": "taskList",
5905                "attrs": {"localId": ""},
5906                "content": [
5907                    {
5908                        "type": "taskItem",
5909                        "attrs": {"localId": "", "state": "TODO"},
5910                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent"}]}]
5911                    },
5912                    {
5913                        "type": "taskList",
5914                        "attrs": {"localId": ""},
5915                        "content": [{
5916                            "type": "taskItem",
5917                            "attrs": {"localId": "", "state": "DONE"},
5918                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "done child"}]}]
5919                        }]
5920                    }
5921                ]
5922            }]
5923        }"#;
5924        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5925        let md = adf_to_markdown(&doc).unwrap();
5926        assert!(md.contains("  - [x] done child"), "got: {md}");
5927        // Round-trip preserves DONE state — nested taskList is a sibling.
5928        let rt_doc = markdown_to_adf(&md).unwrap();
5929        let items = rt_doc.content[0].content.as_ref().unwrap();
5930        assert_eq!(
5931            items[1].node_type, "taskList",
5932            "nested taskList should survive round-trip"
5933        );
5934        let nested_item = &items[1].content.as_ref().unwrap()[0];
5935        assert_eq!(nested_item.attrs.as_ref().unwrap()["state"], "DONE");
5936    }
5937
5938    /// Issue #506: multiple nested taskLists at the same level.
5939    #[test]
5940    fn adf_multiple_nested_tasklists() {
5941        let json = r#"{
5942            "version": 1,
5943            "type": "doc",
5944            "content": [{
5945                "type": "taskList",
5946                "attrs": {"localId": ""},
5947                "content": [
5948                    {
5949                        "type": "taskItem",
5950                        "attrs": {"localId": "", "state": "TODO"},
5951                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "first parent"}]}]
5952                    },
5953                    {
5954                        "type": "taskList",
5955                        "attrs": {"localId": ""},
5956                        "content": [{
5957                            "type": "taskItem",
5958                            "attrs": {"localId": "", "state": "TODO"},
5959                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child A"}]}]
5960                        }]
5961                    },
5962                    {
5963                        "type": "taskItem",
5964                        "attrs": {"localId": "", "state": "TODO"},
5965                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "second parent"}]}]
5966                    },
5967                    {
5968                        "type": "taskList",
5969                        "attrs": {"localId": ""},
5970                        "content": [{
5971                            "type": "taskItem",
5972                            "attrs": {"localId": "", "state": "DONE"},
5973                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child B"}]}]
5974                        }]
5975                    }
5976                ]
5977            }]
5978        }"#;
5979        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5980        let md = adf_to_markdown(&doc).unwrap();
5981        assert!(md.contains("- [ ] first parent"), "got: {md}");
5982        assert!(md.contains("  - [ ] child A"), "got: {md}");
5983        assert!(md.contains("- [ ] second parent"), "got: {md}");
5984        assert!(md.contains("  - [x] child B"), "got: {md}");
5985    }
5986
5987    /// Issue #506: second round-trip is stable (idempotent after first
5988    /// structural normalisation).
5989    #[test]
5990    fn round_trip_nested_tasklist_stable() {
5991        let json = r#"{
5992            "version": 1,
5993            "type": "doc",
5994            "content": [{
5995                "type": "taskList",
5996                "attrs": {"localId": ""},
5997                "content": [
5998                    {
5999                        "type": "taskItem",
6000                        "attrs": {"localId": "", "state": "TODO"},
6001                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent"}]}]
6002                    },
6003                    {
6004                        "type": "taskList",
6005                        "attrs": {"localId": ""},
6006                        "content": [{
6007                            "type": "taskItem",
6008                            "attrs": {"localId": "", "state": "TODO"},
6009                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child"}]}]
6010                        }]
6011                    }
6012                ]
6013            }]
6014        }"#;
6015        let doc: AdfDocument = serde_json::from_str(json).unwrap();
6016        // First round-trip.
6017        let md1 = adf_to_markdown(&doc).unwrap();
6018        let rt1 = markdown_to_adf(&md1).unwrap();
6019        // Second round-trip.
6020        let md2 = adf_to_markdown(&rt1).unwrap();
6021        let rt2 = markdown_to_adf(&md2).unwrap();
6022        // Markdown output should be identical after first normalisation.
6023        assert_eq!(md1, md2, "markdown should be stable across round-trips");
6024        // ADF structure should also be stable.
6025        let rt1_json = serde_json::to_string(&rt1).unwrap();
6026        let rt2_json = serde_json::to_string(&rt2).unwrap();
6027        assert_eq!(
6028            rt1_json, rt2_json,
6029            "ADF should be stable across round-trips"
6030        );
6031    }
6032
6033    /// Issue #506: task item with text and mixed indented sub-content
6034    /// (taskList + non-taskList block).  Exercises the `child_nodes` branch
6035    /// where non-taskList blocks stay as children of the taskItem while
6036    /// taskLists are promoted to siblings.
6037    #[test]
6038    fn task_item_mixed_sub_content_splits_siblings() {
6039        let md = "- [ ] Parent task\n  - [ ] Sub task\n  Some paragraph\n";
6040        let doc = markdown_to_adf(md).unwrap();
6041        let items = doc.content[0].content.as_ref().unwrap();
6042        // taskItem + sibling taskList
6043        assert_eq!(items.len(), 2, "got: {items:?}");
6044        assert_eq!(items[0].node_type, "taskItem");
6045        let parent_content = items[0].content.as_ref().unwrap();
6046        // Inline text + paragraph block (the non-taskList sub-content)
6047        assert!(
6048            parent_content.iter().any(|n| n.node_type == "paragraph"),
6049            "non-taskList sub-content should stay as child: {parent_content:?}"
6050        );
6051        // Sibling taskList
6052        assert_eq!(items[1].node_type, "taskList");
6053    }
6054
6055    /// Issue #506: empty task item with mixed indented sub-content hits the
6056    /// `None` arm of the `task.content` match when promoting taskLists to
6057    /// siblings.
6058    #[test]
6059    fn empty_task_item_mixed_sub_content_none_arm() {
6060        let md = "- [ ] \n  Some paragraph\n  - [ ] Sub task\n";
6061        let doc = markdown_to_adf(md).unwrap();
6062        let items = doc.content[0].content.as_ref().unwrap();
6063        // taskItem (with paragraph child) + sibling taskList
6064        assert_eq!(items.len(), 2, "got: {items:?}");
6065        assert_eq!(items[0].node_type, "taskItem");
6066        let parent_content = items[0].content.as_ref().unwrap();
6067        assert!(
6068            parent_content.iter().any(|n| n.node_type == "paragraph"),
6069            "paragraph should be assigned to taskItem: {parent_content:?}"
6070        );
6071        assert_eq!(items[1].node_type, "taskList");
6072    }
6073
6074    /// Issue #506: task item with text and only non-taskList sub-content
6075    /// (no sibling taskLists).  Exercises the fall-through path where
6076    /// `sibling_task_lists` is empty and child_nodes are appended to
6077    /// the existing task content (Some arm).
6078    #[test]
6079    fn task_item_text_with_non_tasklist_sub_content_only() {
6080        let md = "- [ ] My task\n  Extra paragraph content\n";
6081        let doc = markdown_to_adf(md).unwrap();
6082        let items = doc.content[0].content.as_ref().unwrap();
6083        // Single taskItem — no sibling taskLists to extract.
6084        assert_eq!(items.len(), 1, "got: {items:?}");
6085        assert_eq!(items[0].node_type, "taskItem");
6086        let content = items[0].content.as_ref().unwrap();
6087        // Inline text + sub-paragraph
6088        assert!(
6089            content.iter().any(|n| n.node_type == "paragraph"),
6090            "paragraph sub-content should be a child of taskItem: {content:?}"
6091        );
6092    }
6093
6094    /// Covers the else branch in render_list_item_content where the first
6095    /// child of a list item is a block node (not paragraph, not inline).
6096    #[test]
6097    fn adf_list_item_leading_block_node() {
6098        let json = r#"{
6099            "version": 1,
6100            "type": "doc",
6101            "content": [{
6102                "type": "bulletList",
6103                "content": [{
6104                    "type": "listItem",
6105                    "content": [{
6106                        "type": "codeBlock",
6107                        "attrs": {"language": "rust"},
6108                        "content": [{"type": "text", "text": "let x = 1;"}]
6109                    }]
6110                }]
6111            }]
6112        }"#;
6113        let doc: AdfDocument = serde_json::from_str(json).unwrap();
6114        let md = adf_to_markdown(&doc).unwrap();
6115        assert!(md.contains("```rust"), "got: {md}");
6116        assert!(md.contains("let x = 1;"), "got: {md}");
6117        // Continuation lines must be indented so the block stays inside
6118        // the list item on round-trip (issue #511).
6119        for line in md.lines() {
6120            if line.starts_with("- ") {
6121                continue; // first line with list marker
6122            }
6123            if line.trim().is_empty() {
6124                continue;
6125            }
6126            assert!(
6127                line.starts_with("  "),
6128                "continuation line not indented: {line:?}"
6129            );
6130        }
6131    }
6132
6133    /// Round-trip a codeBlock inside a listItem whose content contains a
6134    /// backtick character — the exact reproducer from issue #511.
6135    #[test]
6136    fn code_block_in_list_item_backtick_roundtrip() {
6137        let json = r#"{
6138            "version": 1,
6139            "type": "doc",
6140            "content": [{
6141                "type": "bulletList",
6142                "content": [{
6143                    "type": "listItem",
6144                    "content": [{
6145                        "type": "codeBlock",
6146                        "attrs": {"language": ""},
6147                        "content": [{"type": "text", "text": "error: some value with a backtick ` at end"}]
6148                    }]
6149                }]
6150            }]
6151        }"#;
6152        let original: AdfDocument = serde_json::from_str(json).unwrap();
6153        let md = adf_to_markdown(&original).unwrap();
6154        let roundtripped = markdown_to_adf(&md).unwrap();
6155        let list = &roundtripped.content[0];
6156        assert_eq!(list.node_type, "bulletList", "top node: {}", list.node_type);
6157        let item = &list.content.as_ref().unwrap()[0];
6158        let first_child = &item.content.as_ref().unwrap()[0];
6159        assert_eq!(
6160            first_child.node_type, "codeBlock",
6161            "expected codeBlock, got: {}",
6162            first_child.node_type
6163        );
6164        let text = first_child.content.as_ref().unwrap()[0]
6165            .text
6166            .as_deref()
6167            .unwrap();
6168        assert_eq!(text, "error: some value with a backtick ` at end");
6169    }
6170
6171    /// Code block with language tag inside a list item round-trips.
6172    #[test]
6173    fn code_block_with_language_in_list_item_roundtrip() {
6174        let json = r#"{
6175            "version": 1,
6176            "type": "doc",
6177            "content": [{
6178                "type": "bulletList",
6179                "content": [{
6180                    "type": "listItem",
6181                    "content": [{
6182                        "type": "codeBlock",
6183                        "attrs": {"language": "rust"},
6184                        "content": [{"type": "text", "text": "fn main() {\n    println!(\"hello\");\n}"}]
6185                    }]
6186                }]
6187            }]
6188        }"#;
6189        let original: AdfDocument = serde_json::from_str(json).unwrap();
6190        let md = adf_to_markdown(&original).unwrap();
6191        let roundtripped = markdown_to_adf(&md).unwrap();
6192        let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
6193        let code = &item.content.as_ref().unwrap()[0];
6194        assert_eq!(code.node_type, "codeBlock");
6195        let lang = code
6196            .attrs
6197            .as_ref()
6198            .and_then(|a| a.get("language"))
6199            .and_then(serde_json::Value::as_str)
6200            .unwrap_or("");
6201        assert_eq!(lang, "rust");
6202        let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
6203        assert!(text.contains("println!"), "code content: {text}");
6204    }
6205
6206    /// Code block in an ordered list item round-trips correctly.
6207    #[test]
6208    fn code_block_in_ordered_list_item_roundtrip() {
6209        let json = r#"{
6210            "version": 1,
6211            "type": "doc",
6212            "content": [{
6213                "type": "orderedList",
6214                "attrs": {"order": 1},
6215                "content": [{
6216                    "type": "listItem",
6217                    "content": [{
6218                        "type": "codeBlock",
6219                        "attrs": {"language": ""},
6220                        "content": [{"type": "text", "text": "backtick ` here"}]
6221                    }]
6222                }]
6223            }]
6224        }"#;
6225        let original: AdfDocument = serde_json::from_str(json).unwrap();
6226        let md = adf_to_markdown(&original).unwrap();
6227        let roundtripped = markdown_to_adf(&md).unwrap();
6228        let list = &roundtripped.content[0];
6229        assert_eq!(list.node_type, "orderedList");
6230        let item = &list.content.as_ref().unwrap()[0];
6231        let code = &item.content.as_ref().unwrap()[0];
6232        assert_eq!(code.node_type, "codeBlock");
6233        let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
6234        assert_eq!(text, "backtick ` here");
6235    }
6236
6237    /// A list item with a code block followed by a paragraph round-trips.
6238    #[test]
6239    fn code_block_then_paragraph_in_list_item() {
6240        let json = r#"{
6241            "version": 1,
6242            "type": "doc",
6243            "content": [{
6244                "type": "bulletList",
6245                "content": [{
6246                    "type": "listItem",
6247                    "content": [
6248                        {
6249                            "type": "codeBlock",
6250                            "attrs": {"language": ""},
6251                            "content": [{"type": "text", "text": "code with ` backtick"}]
6252                        },
6253                        {
6254                            "type": "paragraph",
6255                            "content": [{"type": "text", "text": "description"}]
6256                        }
6257                    ]
6258                }]
6259            }]
6260        }"#;
6261        let original: AdfDocument = serde_json::from_str(json).unwrap();
6262        let md = adf_to_markdown(&original).unwrap();
6263        let roundtripped = markdown_to_adf(&md).unwrap();
6264        let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
6265        let children = item.content.as_ref().unwrap();
6266        assert_eq!(children[0].node_type, "codeBlock");
6267        assert_eq!(children[1].node_type, "paragraph");
6268    }
6269
6270    /// Multiple backticks in code block content round-trip.
6271    #[test]
6272    fn code_block_multiple_backticks_in_list_item() {
6273        let json = r#"{
6274            "version": 1,
6275            "type": "doc",
6276            "content": [{
6277                "type": "bulletList",
6278                "content": [{
6279                    "type": "listItem",
6280                    "content": [{
6281                        "type": "codeBlock",
6282                        "attrs": {"language": ""},
6283                        "content": [{"type": "text", "text": "a ` b `` c ``` d"}]
6284                    }]
6285                }]
6286            }]
6287        }"#;
6288        let original: AdfDocument = serde_json::from_str(json).unwrap();
6289        let md = adf_to_markdown(&original).unwrap();
6290        let roundtripped = markdown_to_adf(&md).unwrap();
6291        let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
6292        let code = &item.content.as_ref().unwrap()[0];
6293        assert_eq!(code.node_type, "codeBlock");
6294        let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
6295        assert_eq!(text, "a ` b `` c ``` d");
6296    }
6297
6298    /// Media as the first child of a list item with a subsequent paragraph
6299    /// exercises the media + sub_lines branch in `parse_list_item_first_line`.
6300    #[test]
6301    fn media_first_child_with_sub_content_in_list_item() {
6302        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
6303          {"type":"listItem","content":[
6304            {"type":"mediaSingle","attrs":{"layout":"center"},
6305             "content":[{"type":"media","attrs":{"type":"file","id":"img-99","collection":"col-x","height":50,"width":100}}]},
6306            {"type":"paragraph","content":[{"type":"text","text":"Caption below"}]}
6307          ]}
6308        ]}]}"#;
6309        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
6310        let md = adf_to_markdown(&doc).unwrap();
6311        let rt = markdown_to_adf(&md).unwrap();
6312        let item = &rt.content[0].content.as_ref().unwrap()[0];
6313        let children = item.content.as_ref().unwrap();
6314        assert_eq!(
6315            children.len(),
6316            2,
6317            "expected 2 children, got {}",
6318            children.len()
6319        );
6320        assert_eq!(children[0].node_type, "mediaSingle");
6321        let media = &children[0].content.as_ref().unwrap()[0];
6322        assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-99");
6323        assert_eq!(children[1].node_type, "paragraph");
6324    }
6325
6326    #[test]
6327    fn inline_bold() {
6328        let doc = markdown_to_adf("Some **bold** text").unwrap();
6329        let content = doc.content[0].content.as_ref().unwrap();
6330        assert!(content.len() >= 3);
6331        let bold_node = &content[1];
6332        assert_eq!(bold_node.text.as_deref(), Some("bold"));
6333        let marks = bold_node.marks.as_ref().unwrap();
6334        assert_eq!(marks[0].mark_type, "strong");
6335    }
6336
6337    #[test]
6338    fn inline_italic() {
6339        let doc = markdown_to_adf("Some *italic* text").unwrap();
6340        let content = doc.content[0].content.as_ref().unwrap();
6341        let italic_node = &content[1];
6342        assert_eq!(italic_node.text.as_deref(), Some("italic"));
6343        let marks = italic_node.marks.as_ref().unwrap();
6344        assert_eq!(marks[0].mark_type, "em");
6345    }
6346
6347    #[test]
6348    fn inline_code() {
6349        let doc = markdown_to_adf("Use `code` here").unwrap();
6350        let content = doc.content[0].content.as_ref().unwrap();
6351        let code_node = &content[1];
6352        assert_eq!(code_node.text.as_deref(), Some("code"));
6353        let marks = code_node.marks.as_ref().unwrap();
6354        assert_eq!(marks[0].mark_type, "code");
6355    }
6356
6357    /// Issue #578: a code-marked text with an internal backtick must be
6358    /// emitted using double-backtick delimiters so it round-trips as a
6359    /// single node rather than being split on the inner backtick.
6360    #[test]
6361    fn inline_code_with_backtick_emitted_with_double_delimiters() {
6362        let doc = AdfDocument {
6363            version: 1,
6364            doc_type: "doc".to_string(),
6365            content: vec![AdfNode::paragraph(vec![
6366                AdfNode::text("Run "),
6367                AdfNode::text_with_marks(
6368                    "ADD `custom_threshold` TEXT NOT NULL",
6369                    vec![AdfMark::code()],
6370                ),
6371                AdfNode::text(" to update the schema."),
6372            ])],
6373        };
6374        let md = adf_to_markdown(&doc).unwrap();
6375        assert!(
6376            md.contains("``ADD `custom_threshold` TEXT NOT NULL``"),
6377            "expected double-backtick delimiters, got: {md}"
6378        );
6379    }
6380
6381    /// Issue #578: double-backtick delimited code spans parse as a single
6382    /// code-marked text node that preserves the embedded single backticks.
6383    #[test]
6384    fn inline_code_double_backtick_delimiters_parse() {
6385        let doc = markdown_to_adf("Run ``ADD `custom_threshold` TEXT NOT NULL`` now").unwrap();
6386        let content = doc.content[0].content.as_ref().unwrap();
6387        assert_eq!(content.len(), 3, "content: {content:?}");
6388        let code_node = &content[1];
6389        assert_eq!(
6390            code_node.text.as_deref(),
6391            Some("ADD `custom_threshold` TEXT NOT NULL")
6392        );
6393        let marks = code_node.marks.as_ref().unwrap();
6394        assert_eq!(marks[0].mark_type, "code");
6395    }
6396
6397    /// Issue #578: the full reproducer — a code-marked text with inner
6398    /// backticks survives ADF → JFM → ADF round-trip intact.
6399    #[test]
6400    fn inline_code_with_backtick_roundtrip() {
6401        let json = r#"{
6402            "version": 1,
6403            "type": "doc",
6404            "content": [{
6405                "type": "paragraph",
6406                "content": [
6407                    {"type": "text", "text": "Run "},
6408                    {
6409                        "type": "text",
6410                        "text": "ADD `custom_threshold` TEXT NOT NULL",
6411                        "marks": [{"type": "code"}]
6412                    },
6413                    {"type": "text", "text": " to update the schema."}
6414                ]
6415            }]
6416        }"#;
6417        let original: AdfDocument = serde_json::from_str(json).unwrap();
6418        let md = adf_to_markdown(&original).unwrap();
6419        let roundtripped = markdown_to_adf(&md).unwrap();
6420        let para = &roundtripped.content[0];
6421        let children = para.content.as_ref().unwrap();
6422        assert_eq!(children.len(), 3, "expected 3 children, got: {children:?}");
6423        assert_eq!(children[0].text.as_deref(), Some("Run "));
6424        assert_eq!(
6425            children[1].text.as_deref(),
6426            Some("ADD `custom_threshold` TEXT NOT NULL")
6427        );
6428        let marks = children[1].marks.as_ref().unwrap();
6429        assert_eq!(marks.len(), 1);
6430        assert_eq!(marks[0].mark_type, "code");
6431        assert_eq!(children[2].text.as_deref(), Some(" to update the schema."));
6432    }
6433
6434    /// A code-marked text containing a run of two backticks should be
6435    /// emitted with triple-backtick delimiters and round-trip intact —
6436    /// the first line of the paragraph also starts with the fence so this
6437    /// exercises the info-string-with-backtick fence-opener rejection.
6438    #[test]
6439    fn inline_code_with_double_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                "x `` y",
6445                vec![AdfMark::code()],
6446            )])],
6447        };
6448        let md = adf_to_markdown(&doc).unwrap();
6449        let roundtripped = markdown_to_adf(&md).unwrap();
6450        let content = roundtripped.content[0].content.as_ref().unwrap();
6451        assert_eq!(content.len(), 1);
6452        assert_eq!(content[0].text.as_deref(), Some("x `` y"));
6453        let marks = content[0].marks.as_ref().unwrap();
6454        assert_eq!(marks[0].mark_type, "code");
6455    }
6456
6457    /// A code-marked text that begins with a backtick must be padded on
6458    /// both sides so the CommonMark space-stripping rule reconstructs it.
6459    #[test]
6460    fn inline_code_leading_backtick_roundtrip() {
6461        let doc = AdfDocument {
6462            version: 1,
6463            doc_type: "doc".to_string(),
6464            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6465                "`start",
6466                vec![AdfMark::code()],
6467            )])],
6468        };
6469        let md = adf_to_markdown(&doc).unwrap();
6470        let roundtripped = markdown_to_adf(&md).unwrap();
6471        let content = roundtripped.content[0].content.as_ref().unwrap();
6472        assert_eq!(content[0].text.as_deref(), Some("`start"));
6473        assert_eq!(content[0].marks.as_ref().unwrap()[0].mark_type, "code");
6474    }
6475
6476    /// A code-marked text that ends with a backtick must also survive.
6477    #[test]
6478    fn inline_code_trailing_backtick_roundtrip() {
6479        let doc = AdfDocument {
6480            version: 1,
6481            doc_type: "doc".to_string(),
6482            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6483                "end`",
6484                vec![AdfMark::code()],
6485            )])],
6486        };
6487        let md = adf_to_markdown(&doc).unwrap();
6488        let roundtripped = markdown_to_adf(&md).unwrap();
6489        let content = roundtripped.content[0].content.as_ref().unwrap();
6490        assert_eq!(content[0].text.as_deref(), Some("end`"));
6491    }
6492
6493    /// Content that both begins and ends with a space (but is not all
6494    /// spaces) needs padding so the stripping rule leaves it intact.
6495    #[test]
6496    fn inline_code_space_padded_content_roundtrip() {
6497        let doc = AdfDocument {
6498            version: 1,
6499            doc_type: "doc".to_string(),
6500            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6501                " foo ",
6502                vec![AdfMark::code()],
6503            )])],
6504        };
6505        let md = adf_to_markdown(&doc).unwrap();
6506        let roundtripped = markdown_to_adf(&md).unwrap();
6507        let content = roundtripped.content[0].content.as_ref().unwrap();
6508        assert_eq!(content[0].text.as_deref(), Some(" foo "));
6509    }
6510
6511    /// All-space content must round-trip without the stripping rule
6512    /// kicking in (per CommonMark: all-space content is not stripped).
6513    #[test]
6514    fn inline_code_all_spaces_roundtrip() {
6515        let doc = AdfDocument {
6516            version: 1,
6517            doc_type: "doc".to_string(),
6518            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6519                "   ",
6520                vec![AdfMark::code()],
6521            )])],
6522        };
6523        let md = adf_to_markdown(&doc).unwrap();
6524        let roundtripped = markdown_to_adf(&md).unwrap();
6525        let content = roundtripped.content[0].content.as_ref().unwrap();
6526        assert_eq!(content[0].text.as_deref(), Some("   "));
6527    }
6528
6529    /// A code+link mark where the code text contains a backtick must also
6530    /// round-trip — verifies the link branch of code-span rendering.
6531    #[test]
6532    fn inline_code_with_link_and_backtick_roundtrip() {
6533        let doc = AdfDocument {
6534            version: 1,
6535            doc_type: "doc".to_string(),
6536            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6537                "fn `inner`",
6538                vec![AdfMark::code(), AdfMark::link("https://example.com")],
6539            )])],
6540        };
6541        let md = adf_to_markdown(&doc).unwrap();
6542        assert!(
6543            md.contains("`` fn `inner` ``"),
6544            "expected padded double-backtick delimiters inside link, got: {md}"
6545        );
6546        let roundtripped = markdown_to_adf(&md).unwrap();
6547        let content = roundtripped.content[0].content.as_ref().unwrap();
6548        assert_eq!(content[0].text.as_deref(), Some("fn `inner`"));
6549        let mark_types: Vec<&str> = content[0]
6550            .marks
6551            .as_ref()
6552            .unwrap()
6553            .iter()
6554            .map(|m| m.mark_type.as_str())
6555            .collect();
6556        assert!(mark_types.contains(&"code"));
6557        assert!(mark_types.contains(&"link"));
6558    }
6559
6560    /// Unmatched opening backticks must not be parsed as a code span.
6561    #[test]
6562    fn inline_code_unmatched_run_is_plain_text() {
6563        let doc = markdown_to_adf("foo ``bar baz").unwrap();
6564        let content = doc.content[0].content.as_ref().unwrap();
6565        assert_eq!(content.len(), 1);
6566        assert_eq!(content[0].text.as_deref(), Some("foo ``bar baz"));
6567        assert!(content[0].marks.is_none());
6568    }
6569
6570    /// Mismatched delimiter lengths must not form a code span.  Per
6571    /// CommonMark the opening 2-backtick run and the trailing 1-backtick
6572    /// run never form a valid code span and the characters stay literal.
6573    #[test]
6574    fn inline_code_mismatched_delimiters_is_plain_text() {
6575        let doc = markdown_to_adf("``foo` bar").unwrap();
6576        let content = doc.content[0].content.as_ref().unwrap();
6577        assert_eq!(content.len(), 1);
6578        assert_eq!(content[0].text.as_deref(), Some("``foo` bar"));
6579        assert!(content[0].marks.is_none());
6580    }
6581
6582    #[test]
6583    fn inline_code_delimiter_chooses_correct_length() {
6584        assert_eq!(inline_code_delimiter("no ticks"), (1, false));
6585        assert_eq!(inline_code_delimiter("one ` here"), (2, false));
6586        assert_eq!(inline_code_delimiter("two `` here"), (3, false));
6587        assert_eq!(inline_code_delimiter("three ``` here"), (4, false));
6588        assert_eq!(inline_code_delimiter("`leading"), (2, true));
6589        assert_eq!(inline_code_delimiter("trailing`"), (2, true));
6590        assert_eq!(inline_code_delimiter(" foo "), (1, true));
6591        assert_eq!(inline_code_delimiter(" "), (1, false));
6592        assert_eq!(inline_code_delimiter("   "), (1, false));
6593        assert_eq!(inline_code_delimiter(" foo"), (1, false));
6594    }
6595
6596    #[test]
6597    fn try_parse_inline_code_strips_paired_spaces() {
6598        let (end, content) = try_parse_inline_code("`` `foo` ``", 0).unwrap();
6599        assert_eq!(end, 11);
6600        assert_eq!(content, "`foo`");
6601    }
6602
6603    #[test]
6604    fn try_parse_inline_code_all_space_content_is_preserved() {
6605        let (_end, content) = try_parse_inline_code("`   `", 0).unwrap();
6606        assert_eq!(content, "   ");
6607    }
6608
6609    #[test]
6610    fn try_parse_inline_code_single_run_matches_first_close() {
6611        let (end, content) = try_parse_inline_code("`foo` tail", 0).unwrap();
6612        assert_eq!(end, 5);
6613        assert_eq!(content, "foo");
6614    }
6615
6616    #[test]
6617    fn try_parse_inline_code_no_match_returns_none() {
6618        assert!(try_parse_inline_code("``unmatched", 0).is_none());
6619        assert!(try_parse_inline_code("plain text", 0).is_none());
6620    }
6621
6622    #[test]
6623    fn is_code_fence_opener_rejects_info_with_backtick() {
6624        assert!(is_code_fence_opener("```"));
6625        assert!(is_code_fence_opener("```rust"));
6626        assert!(is_code_fence_opener("```\"\""));
6627        assert!(!is_code_fence_opener("```x `` y```"));
6628        assert!(!is_code_fence_opener("``not-enough"));
6629        assert!(!is_code_fence_opener("no fence"));
6630    }
6631
6632    #[test]
6633    fn inline_strikethrough() {
6634        let doc = markdown_to_adf("Some ~~deleted~~ text").unwrap();
6635        let content = doc.content[0].content.as_ref().unwrap();
6636        let strike_node = &content[1];
6637        assert_eq!(strike_node.text.as_deref(), Some("deleted"));
6638        let marks = strike_node.marks.as_ref().unwrap();
6639        assert_eq!(marks[0].mark_type, "strike");
6640    }
6641
6642    #[test]
6643    fn inline_link() {
6644        let doc = markdown_to_adf("Click [here](https://example.com) now").unwrap();
6645        let content = doc.content[0].content.as_ref().unwrap();
6646        let link_node = &content[1];
6647        assert_eq!(link_node.text.as_deref(), Some("here"));
6648        let marks = link_node.marks.as_ref().unwrap();
6649        assert_eq!(marks[0].mark_type, "link");
6650    }
6651
6652    #[test]
6653    fn block_image() {
6654        let md = "![Alt text](https://example.com/image.png)";
6655        let doc = markdown_to_adf(md).unwrap();
6656        assert_eq!(doc.content[0].node_type, "mediaSingle");
6657    }
6658
6659    #[test]
6660    fn table() {
6661        let md = "| A | B |\n| --- | --- |\n| 1 | 2 |";
6662        let doc = markdown_to_adf(md).unwrap();
6663        assert_eq!(doc.content[0].node_type, "table");
6664        let rows = doc.content[0].content.as_ref().unwrap();
6665        assert_eq!(rows.len(), 2); // header + 1 body row
6666    }
6667
6668    // ── adf_to_markdown tests ───────────────────────────────────────
6669
6670    #[test]
6671    fn adf_paragraph_to_markdown() {
6672        let doc = AdfDocument {
6673            version: 1,
6674            doc_type: "doc".to_string(),
6675            content: vec![AdfNode::paragraph(vec![AdfNode::text("Hello world")])],
6676        };
6677        let md = adf_to_markdown(&doc).unwrap();
6678        assert_eq!(md.trim(), "Hello world");
6679    }
6680
6681    #[test]
6682    fn adf_heading_to_markdown() {
6683        let doc = AdfDocument {
6684            version: 1,
6685            doc_type: "doc".to_string(),
6686            content: vec![AdfNode::heading(2, vec![AdfNode::text("Title")])],
6687        };
6688        let md = adf_to_markdown(&doc).unwrap();
6689        assert_eq!(md.trim(), "## Title");
6690    }
6691
6692    #[test]
6693    fn adf_bold_to_markdown() {
6694        let doc = AdfDocument {
6695            version: 1,
6696            doc_type: "doc".to_string(),
6697            content: vec![AdfNode::paragraph(vec![
6698                AdfNode::text("Normal "),
6699                AdfNode::text_with_marks("bold", vec![AdfMark::strong()]),
6700                AdfNode::text(" text"),
6701            ])],
6702        };
6703        let md = adf_to_markdown(&doc).unwrap();
6704        assert_eq!(md.trim(), "Normal **bold** text");
6705    }
6706
6707    #[test]
6708    fn adf_code_block_to_markdown() {
6709        let doc = AdfDocument {
6710            version: 1,
6711            doc_type: "doc".to_string(),
6712            content: vec![AdfNode::code_block(Some("rust"), "let x = 1;")],
6713        };
6714        let md = adf_to_markdown(&doc).unwrap();
6715        assert!(md.contains("```rust"));
6716        assert!(md.contains("let x = 1;"));
6717        assert!(md.contains("```"));
6718    }
6719
6720    #[test]
6721    fn adf_rule_to_markdown() {
6722        let doc = AdfDocument {
6723            version: 1,
6724            doc_type: "doc".to_string(),
6725            content: vec![AdfNode::rule()],
6726        };
6727        let md = adf_to_markdown(&doc).unwrap();
6728        assert!(md.contains("---"));
6729    }
6730
6731    #[test]
6732    fn adf_bullet_list_to_markdown() {
6733        let doc = AdfDocument {
6734            version: 1,
6735            doc_type: "doc".to_string(),
6736            content: vec![AdfNode::bullet_list(vec![
6737                AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("A")])]),
6738                AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("B")])]),
6739            ])],
6740        };
6741        let md = adf_to_markdown(&doc).unwrap();
6742        assert!(md.contains("- A"));
6743        assert!(md.contains("- B"));
6744    }
6745
6746    #[test]
6747    fn adf_link_to_markdown() {
6748        let doc = AdfDocument {
6749            version: 1,
6750            doc_type: "doc".to_string(),
6751            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6752                "click",
6753                vec![AdfMark::link("https://example.com")],
6754            )])],
6755        };
6756        let md = adf_to_markdown(&doc).unwrap();
6757        assert_eq!(md.trim(), "[click](https://example.com)");
6758    }
6759
6760    #[test]
6761    fn unsupported_block_preserved_as_json() {
6762        let doc = AdfDocument {
6763            version: 1,
6764            doc_type: "doc".to_string(),
6765            content: vec![AdfNode {
6766                node_type: "unknownBlock".to_string(),
6767                attrs: Some(serde_json::json!({"key": "value"})),
6768                content: None,
6769                text: None,
6770                marks: None,
6771                local_id: None,
6772                parameters: None,
6773            }],
6774        };
6775        let md = adf_to_markdown(&doc).unwrap();
6776        assert!(md.contains("```adf-unsupported"));
6777        assert!(md.contains("\"unknownBlock\""));
6778    }
6779
6780    #[test]
6781    fn unsupported_block_round_trips() {
6782        let original = AdfDocument {
6783            version: 1,
6784            doc_type: "doc".to_string(),
6785            content: vec![AdfNode {
6786                node_type: "unknownBlock".to_string(),
6787                attrs: Some(serde_json::json!({"key": "value"})),
6788                content: None,
6789                text: None,
6790                marks: None,
6791                local_id: None,
6792                parameters: None,
6793            }],
6794        };
6795        let md = adf_to_markdown(&original).unwrap();
6796        let restored = markdown_to_adf(&md).unwrap();
6797        assert_eq!(restored.content[0].node_type, "unknownBlock");
6798        assert_eq!(restored.content[0].attrs.as_ref().unwrap()["key"], "value");
6799    }
6800
6801    // ── Round-trip tests ────────────────────────────────────────────
6802
6803    #[test]
6804    fn round_trip_simple_document() {
6805        let md = "# Hello\n\nSome text with **bold** and *italic*.\n\n- Item 1\n- Item 2\n";
6806        let adf = markdown_to_adf(md).unwrap();
6807        let restored = adf_to_markdown(&adf).unwrap();
6808
6809        assert!(restored.contains("# Hello"));
6810        assert!(restored.contains("**bold**"));
6811        assert!(restored.contains("*italic*"));
6812        assert!(restored.contains("- Item 1"));
6813        assert!(restored.contains("- Item 2"));
6814    }
6815
6816    #[test]
6817    fn round_trip_code_block() {
6818        let md = "```python\nprint('hello')\n```\n";
6819        let adf = markdown_to_adf(md).unwrap();
6820        let restored = adf_to_markdown(&adf).unwrap();
6821
6822        assert!(restored.contains("```python"));
6823        assert!(restored.contains("print('hello')"));
6824    }
6825
6826    #[test]
6827    fn round_trip_code_block_no_attrs() {
6828        let adf_json = r#"{"version":1,"type":"doc","content":[
6829            {"type":"codeBlock","content":[{"type":"text","text":"plain code"}]}
6830        ]}"#;
6831        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
6832        assert!(doc.content[0].attrs.is_none());
6833        let md = adf_to_markdown(&doc).unwrap();
6834        let round_tripped = markdown_to_adf(&md).unwrap();
6835        assert!(round_tripped.content[0].attrs.is_none());
6836    }
6837
6838    #[test]
6839    fn round_trip_code_block_empty_language() {
6840        let adf_json = r#"{"version":1,"type":"doc","content":[
6841            {"type":"codeBlock","attrs":{"language":""},"content":[{"type":"text","text":"simple code block no backtick"}]}
6842        ]}"#;
6843        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
6844        let attrs = doc.content[0].attrs.as_ref().unwrap();
6845        assert_eq!(attrs["language"], "");
6846        let md = adf_to_markdown(&doc).unwrap();
6847        let round_tripped = markdown_to_adf(&md).unwrap();
6848        let rt_attrs = round_tripped.content[0].attrs.as_ref().unwrap();
6849        assert_eq!(rt_attrs["language"], "");
6850    }
6851
6852    #[test]
6853    fn round_trip_code_block_with_language() {
6854        let adf_json = r#"{"version":1,"type":"doc","content":[
6855            {"type":"codeBlock","attrs":{"language":"python"},"content":[{"type":"text","text":"print('hi')"}]}
6856        ]}"#;
6857        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
6858        let md = adf_to_markdown(&doc).unwrap();
6859        let round_tripped = markdown_to_adf(&md).unwrap();
6860        let rt_attrs = round_tripped.content[0].attrs.as_ref().unwrap();
6861        assert_eq!(rt_attrs["language"], "python");
6862    }
6863
6864    #[test]
6865    fn multiple_paragraphs() {
6866        let md = "First paragraph.\n\nSecond paragraph.\n";
6867        let adf = markdown_to_adf(md).unwrap();
6868        assert_eq!(adf.content.len(), 2);
6869        assert_eq!(adf.content[0].node_type, "paragraph");
6870        assert_eq!(adf.content[1].node_type, "paragraph");
6871    }
6872
6873    // ── Additional markdown_to_adf tests ───────────────────────────────
6874
6875    #[test]
6876    fn horizontal_rule_underscores() {
6877        let doc = markdown_to_adf("___").unwrap();
6878        assert_eq!(doc.content[0].node_type, "rule");
6879    }
6880
6881    #[test]
6882    fn not_a_horizontal_rule_too_short() {
6883        let doc = markdown_to_adf("--").unwrap();
6884        assert_eq!(doc.content[0].node_type, "paragraph");
6885    }
6886
6887    #[test]
6888    fn bullet_list_star_marker() {
6889        let md = "* Apple\n* Banana";
6890        let doc = markdown_to_adf(md).unwrap();
6891        assert_eq!(doc.content[0].node_type, "bulletList");
6892        let items = doc.content[0].content.as_ref().unwrap();
6893        assert_eq!(items.len(), 2);
6894    }
6895
6896    #[test]
6897    fn bullet_list_plus_marker() {
6898        let md = "+ One\n+ Two";
6899        let doc = markdown_to_adf(md).unwrap();
6900        assert_eq!(doc.content[0].node_type, "bulletList");
6901    }
6902
6903    #[test]
6904    fn ordered_list_non_one_start() {
6905        let md = "5. Fifth\n6. Sixth";
6906        let doc = markdown_to_adf(md).unwrap();
6907        let node = &doc.content[0];
6908        assert_eq!(node.node_type, "orderedList");
6909        let attrs = node.attrs.as_ref().unwrap();
6910        assert_eq!(attrs["order"], 5);
6911    }
6912
6913    #[test]
6914    fn ordered_list_start_at_one_omits_order_attr() {
6915        // Issue #547: order=1 is the default and must be omitted from attrs
6916        // so that ADF→JFM→ADF round-trip is byte-identical for the common
6917        // case where the source ADF has no attrs object on orderedList.
6918        let md = "1. First\n2. Second";
6919        let doc = markdown_to_adf(md).unwrap();
6920        let node = &doc.content[0];
6921        assert_eq!(node.node_type, "orderedList");
6922        assert!(
6923            node.attrs.is_none(),
6924            "attrs should be omitted when order=1, got: {:?}",
6925            node.attrs
6926        );
6927    }
6928
6929    #[test]
6930    fn blockquote_bare_marker() {
6931        // ">" with no space after
6932        let md = ">quoted text";
6933        let doc = markdown_to_adf(md).unwrap();
6934        assert_eq!(doc.content[0].node_type, "blockquote");
6935    }
6936
6937    #[test]
6938    fn image_no_alt() {
6939        let md = "![](https://example.com/img.png)";
6940        let doc = markdown_to_adf(md).unwrap();
6941        let node = &doc.content[0];
6942        assert_eq!(node.node_type, "mediaSingle");
6943        // media child should have no alt attr
6944        let media = &node.content.as_ref().unwrap()[0];
6945        let attrs = media.attrs.as_ref().unwrap();
6946        assert!(attrs.get("alt").is_none());
6947    }
6948
6949    #[test]
6950    fn image_with_alt() {
6951        let md = "![A photo](https://example.com/photo.jpg)";
6952        let doc = markdown_to_adf(md).unwrap();
6953        let media = &doc.content[0].content.as_ref().unwrap()[0];
6954        let attrs = media.attrs.as_ref().unwrap();
6955        assert_eq!(attrs["alt"], "A photo");
6956    }
6957
6958    #[test]
6959    fn table_multi_body_rows() {
6960        let md = "| H1 | H2 |\n| --- | --- |\n| a | b |\n| c | d |";
6961        let doc = markdown_to_adf(md).unwrap();
6962        let rows = doc.content[0].content.as_ref().unwrap();
6963        assert_eq!(rows.len(), 3); // header + 2 body rows
6964                                   // First row cells are tableHeader
6965        let header_cells = rows[0].content.as_ref().unwrap();
6966        assert_eq!(header_cells[0].node_type, "tableHeader");
6967        // Body row cells are tableCell
6968        let body_cells = rows[1].content.as_ref().unwrap();
6969        assert_eq!(body_cells[0].node_type, "tableCell");
6970    }
6971
6972    #[test]
6973    fn table_no_separator_is_not_table() {
6974        // Pipe characters without a separator row should not parse as table
6975        let md = "| not | a table |";
6976        let doc = markdown_to_adf(md).unwrap();
6977        assert_eq!(doc.content[0].node_type, "paragraph");
6978    }
6979
6980    #[test]
6981    fn inline_underscore_bold() {
6982        let doc = markdown_to_adf("Some __bold__ text").unwrap();
6983        let content = doc.content[0].content.as_ref().unwrap();
6984        let bold_node = &content[1];
6985        assert_eq!(bold_node.text.as_deref(), Some("bold"));
6986        let marks = bold_node.marks.as_ref().unwrap();
6987        assert_eq!(marks[0].mark_type, "strong");
6988    }
6989
6990    #[test]
6991    fn inline_underscore_italic() {
6992        let doc = markdown_to_adf("Some _italic_ text").unwrap();
6993        let content = doc.content[0].content.as_ref().unwrap();
6994        let italic_node = &content[1];
6995        assert_eq!(italic_node.text.as_deref(), Some("italic"));
6996        let marks = italic_node.marks.as_ref().unwrap();
6997        assert_eq!(marks[0].mark_type, "em");
6998    }
6999
7000    #[test]
7001    fn intraword_underscore_not_emphasis() {
7002        // Single intraword underscore pair: do_something_useful
7003        let doc = markdown_to_adf("call do_something_useful now").unwrap();
7004        let content = doc.content[0].content.as_ref().unwrap();
7005        assert_eq!(content.len(), 1, "should be a single text node");
7006        assert_eq!(
7007            content[0].text.as_deref(),
7008            Some("call do_something_useful now")
7009        );
7010        assert!(content[0].marks.is_none());
7011    }
7012
7013    #[test]
7014    fn intraword_underscore_multiple() {
7015        // Multiple intraword underscores: a_b_c_d
7016        let doc = markdown_to_adf("use a_b_c_d here").unwrap();
7017        let content = doc.content[0].content.as_ref().unwrap();
7018        assert_eq!(content.len(), 1);
7019        assert_eq!(content[0].text.as_deref(), Some("use a_b_c_d here"));
7020        assert!(content[0].marks.is_none());
7021    }
7022
7023    #[test]
7024    fn intraword_double_underscore_not_bold() {
7025        // Intraword double underscores: foo__bar__baz
7026        let doc = markdown_to_adf("foo__bar__baz").unwrap();
7027        let content = doc.content[0].content.as_ref().unwrap();
7028        assert_eq!(content.len(), 1);
7029        assert_eq!(content[0].text.as_deref(), Some("foo__bar__baz"));
7030        assert!(content[0].marks.is_none());
7031    }
7032
7033    #[test]
7034    fn intraword_triple_underscore_not_bold_italic() {
7035        // Intraword triple underscores: x___y___z
7036        let doc = markdown_to_adf("x___y___z").unwrap();
7037        let content = doc.content[0].content.as_ref().unwrap();
7038        assert_eq!(content.len(), 1);
7039        assert_eq!(content[0].text.as_deref(), Some("x___y___z"));
7040        assert!(content[0].marks.is_none());
7041    }
7042
7043    #[test]
7044    fn underscore_emphasis_still_works_with_spaces() {
7045        // Normal emphasis with spaces around delimiters should still work
7046        let doc = markdown_to_adf("some _italic_ here").unwrap();
7047        let content = doc.content[0].content.as_ref().unwrap();
7048        assert_eq!(content.len(), 3);
7049        assert_eq!(content[1].text.as_deref(), Some("italic"));
7050        let marks = content[1].marks.as_ref().unwrap();
7051        assert_eq!(marks[0].mark_type, "em");
7052    }
7053
7054    #[test]
7055    fn underscore_bold_still_works_with_spaces() {
7056        // Normal bold with spaces around delimiters should still work
7057        let doc = markdown_to_adf("some __bold__ here").unwrap();
7058        let content = doc.content[0].content.as_ref().unwrap();
7059        assert_eq!(content.len(), 3);
7060        assert_eq!(content[1].text.as_deref(), Some("bold"));
7061        let marks = content[1].marks.as_ref().unwrap();
7062        assert_eq!(marks[0].mark_type, "strong");
7063    }
7064
7065    #[test]
7066    fn intraword_underscore_closing_only() {
7067        // Opening _ is valid (preceded by space) but closing _ is intraword: _foo_bar
7068        let doc = markdown_to_adf("_foo_bar").unwrap();
7069        let content = doc.content[0].content.as_ref().unwrap();
7070        assert_eq!(content.len(), 1);
7071        assert_eq!(content[0].text.as_deref(), Some("_foo_bar"));
7072    }
7073
7074    #[test]
7075    fn intraword_double_underscore_closing_only() {
7076        // Opening __ is valid (at start) but closing __ is intraword: __foo__bar
7077        let doc = markdown_to_adf("__foo__bar").unwrap();
7078        let content = doc.content[0].content.as_ref().unwrap();
7079        assert_eq!(content.len(), 1);
7080        assert_eq!(content[0].text.as_deref(), Some("__foo__bar"));
7081    }
7082
7083    #[test]
7084    fn intraword_triple_underscore_closing_only() {
7085        // Opening ___ is valid (at start) but closing ___ is intraword: ___foo___bar
7086        let doc = markdown_to_adf("___foo___bar").unwrap();
7087        let content = doc.content[0].content.as_ref().unwrap();
7088        assert_eq!(content.len(), 1);
7089        assert_eq!(content[0].text.as_deref(), Some("___foo___bar"));
7090    }
7091
7092    #[test]
7093    fn asterisk_emphasis_unaffected_by_intraword_fix() {
7094        // Asterisks should still work for intraword emphasis (CommonMark allows this)
7095        let doc = markdown_to_adf("foo*bar*baz").unwrap();
7096        let content = doc.content[0].content.as_ref().unwrap();
7097        // Asterisks CAN be intraword emphasis per CommonMark
7098        assert!(content.len() > 1 || content[0].marks.is_some());
7099    }
7100
7101    #[test]
7102    fn intraword_underscore_at_start_of_text() {
7103        // Underscore at start of text is not intraword (no preceding alphanumeric)
7104        let doc = markdown_to_adf("_italic_ word").unwrap();
7105        let content = doc.content[0].content.as_ref().unwrap();
7106        assert_eq!(content[0].text.as_deref(), Some("italic"));
7107        let marks = content[0].marks.as_ref().unwrap();
7108        assert_eq!(marks[0].mark_type, "em");
7109    }
7110
7111    #[test]
7112    fn intraword_underscore_at_end_of_text() {
7113        // Underscore at end of text is not intraword (no following alphanumeric)
7114        let doc = markdown_to_adf("word _italic_").unwrap();
7115        let content = doc.content[0].content.as_ref().unwrap();
7116        let last = content.last().unwrap();
7117        assert_eq!(last.text.as_deref(), Some("italic"));
7118        let marks = last.marks.as_ref().unwrap();
7119        assert_eq!(marks[0].mark_type, "em");
7120    }
7121
7122    #[test]
7123    fn intraword_underscore_opening_only() {
7124        // Opening underscore is intraword but closing is not: a_b c_d
7125        // The first _ is intraword (a_b), so it shouldn't open emphasis
7126        let doc = markdown_to_adf("a_b c_d").unwrap();
7127        let content = doc.content[0].content.as_ref().unwrap();
7128        assert_eq!(content.len(), 1);
7129        assert_eq!(content[0].text.as_deref(), Some("a_b c_d"));
7130    }
7131
7132    #[test]
7133    fn intraword_underscore_roundtrip() {
7134        // The original reproducer from issue #438
7135        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"call the do_something_useful function"}]}]}"#;
7136        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7137        let jfm = adf_to_markdown(&adf).unwrap();
7138        let roundtripped = markdown_to_adf(&jfm).unwrap();
7139        let content = roundtripped.content[0].content.as_ref().unwrap();
7140        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7141        assert_eq!(
7142            content[0].text.as_deref(),
7143            Some("call the do_something_useful function")
7144        );
7145        assert!(content[0].marks.is_none());
7146    }
7147
7148    #[test]
7149    fn asterisk_emphasis_roundtrip() {
7150        // The original reproducer from issue #452
7151        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Status: *confirmed* and active"}]}]}"#;
7152        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7153        let jfm = adf_to_markdown(&adf).unwrap();
7154        let roundtripped = markdown_to_adf(&jfm).unwrap();
7155        let content = roundtripped.content[0].content.as_ref().unwrap();
7156        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7157        assert_eq!(
7158            content[0].text.as_deref(),
7159            Some("Status: *confirmed* and active")
7160        );
7161        assert!(content[0].marks.is_none());
7162    }
7163
7164    #[test]
7165    fn double_asterisk_roundtrip() {
7166        // **bold** delimiters in plain text should not become strong marks
7167        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use **kwargs in Python"}]}]}"#;
7168        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7169        let jfm = adf_to_markdown(&adf).unwrap();
7170        let roundtripped = markdown_to_adf(&jfm).unwrap();
7171        let content = roundtripped.content[0].content.as_ref().unwrap();
7172        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7173        assert_eq!(content[0].text.as_deref(), Some("Use **kwargs in Python"));
7174        assert!(content[0].marks.is_none());
7175    }
7176
7177    #[test]
7178    fn asterisk_with_em_mark_roundtrip() {
7179        // Text that already has an em mark should preserve both the mark and escaped content
7180        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a*b","marks":[{"type":"em"}]}]}]}"#;
7181        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7182        let jfm = adf_to_markdown(&adf).unwrap();
7183        let roundtripped = markdown_to_adf(&jfm).unwrap();
7184        let content = roundtripped.content[0].content.as_ref().unwrap();
7185        // Find the node with em mark
7186        let em_node = content.iter().find(|n| {
7187            n.marks
7188                .as_ref()
7189                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "em"))
7190        });
7191        assert!(em_node.is_some(), "should have an em-marked node");
7192        assert_eq!(em_node.unwrap().text.as_deref(), Some("a*b"));
7193    }
7194
7195    #[test]
7196    fn lone_asterisk_roundtrip() {
7197        // A single asterisk that cannot form emphasis should round-trip
7198        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"rating: 5 * stars"}]}]}"#;
7199        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7200        let jfm = adf_to_markdown(&adf).unwrap();
7201        let roundtripped = markdown_to_adf(&jfm).unwrap();
7202        let content = roundtripped.content[0].content.as_ref().unwrap();
7203        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7204        assert_eq!(content[0].text.as_deref(), Some("rating: 5 * stars"));
7205    }
7206
7207    #[test]
7208    fn escape_emphasis_markers_unit() {
7209        assert_eq!(escape_emphasis_markers("hello"), "hello");
7210        assert_eq!(escape_emphasis_markers("*bold*"), r"\*bold\*");
7211        assert_eq!(escape_emphasis_markers("**strong**"), r"\*\*strong\*\*");
7212        assert_eq!(escape_emphasis_markers("no stars"), "no stars");
7213        assert_eq!(escape_emphasis_markers("a * b"), r"a \* b");
7214        assert_eq!(escape_emphasis_markers(""), "");
7215    }
7216
7217    #[test]
7218    fn escape_emphasis_markers_underscore_intraword() {
7219        // Intraword underscores (alnum on both sides within the node) are
7220        // left as-is — the JFM parser will reject them as emphasis.
7221        assert_eq!(escape_emphasis_markers("foo_bar"), "foo_bar");
7222        assert_eq!(escape_emphasis_markers("a_b_c"), "a_b_c");
7223        assert_eq!(escape_emphasis_markers("foo__bar"), "foo__bar");
7224        assert_eq!(
7225            escape_emphasis_markers("call do_something_useful"),
7226            "call do_something_useful"
7227        );
7228    }
7229
7230    #[test]
7231    fn escape_emphasis_markers_underscore_at_boundary() {
7232        // Leading and trailing underscores get escaped — adjacent text nodes
7233        // could supply the alphanumeric needed to close emphasis (issue #554).
7234        assert_eq!(escape_emphasis_markers("_Action"), r"\_Action");
7235        assert_eq!(escape_emphasis_markers("Action_"), r"Action\_");
7236        assert_eq!(escape_emphasis_markers("_ "), r"\_ ");
7237        assert_eq!(escape_emphasis_markers(" _"), r" \_");
7238        assert_eq!(escape_emphasis_markers("_"), r"\_");
7239    }
7240
7241    #[test]
7242    fn escape_emphasis_markers_underscore_with_punctuation() {
7243        // Underscores adjacent to punctuation (not alphanumeric) get escaped.
7244        assert_eq!(escape_emphasis_markers("foo _bar"), r"foo \_bar");
7245        assert_eq!(escape_emphasis_markers("foo_ bar"), r"foo\_ bar");
7246        assert_eq!(escape_emphasis_markers("(_x_)"), r"(\_x\_)");
7247    }
7248
7249    #[test]
7250    fn find_unescaped_skips_backslash_escaped() {
7251        // Escaped `**` should not be found
7252        assert_eq!(find_unescaped(r"a\*\*b**", "**"), Some(6));
7253        // No unescaped match at all
7254        assert_eq!(find_unescaped(r"a\*\*b", "**"), None);
7255        // Plain match without any escaping
7256        assert_eq!(find_unescaped("a**b", "**"), Some(1));
7257        // Empty haystack
7258        assert_eq!(find_unescaped("", "**"), None);
7259    }
7260
7261    #[test]
7262    fn find_unescaped_char_skips_backslash_escaped() {
7263        // Escaped `*` should not be found
7264        assert_eq!(find_unescaped_char(r"a\*b*", b'*'), Some(4));
7265        // No unescaped match at all
7266        assert_eq!(find_unescaped_char(r"\*", b'*'), None);
7267        // Plain match
7268        assert_eq!(find_unescaped_char("a*b", b'*'), Some(1));
7269        // Empty haystack
7270        assert_eq!(find_unescaped_char("", b'*'), None);
7271    }
7272
7273    #[test]
7274    fn double_asterisk_in_strong_mark_roundtrip() {
7275        // Text with ** inside a strong mark should preserve the literal **
7276        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"call **kwargs","marks":[{"type":"strong"}]}]}]}"#;
7277        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7278        let jfm = adf_to_markdown(&adf).unwrap();
7279        let roundtripped = markdown_to_adf(&jfm).unwrap();
7280        let content = roundtripped.content[0].content.as_ref().unwrap();
7281        let strong_node = content.iter().find(|n| {
7282            n.marks
7283                .as_ref()
7284                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
7285        });
7286        assert!(strong_node.is_some(), "should have a strong-marked node");
7287        assert_eq!(strong_node.unwrap().text.as_deref(), Some("call **kwargs"));
7288    }
7289
7290    #[test]
7291    fn backtick_code_roundtrip() {
7292        // The original reproducer from issue #453
7293        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Set `max_retries` to 3 in the config"}]}]}"#;
7294        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7295        let jfm = adf_to_markdown(&adf).unwrap();
7296        let roundtripped = markdown_to_adf(&jfm).unwrap();
7297        let content = roundtripped.content[0].content.as_ref().unwrap();
7298        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7299        assert_eq!(
7300            content[0].text.as_deref(),
7301            Some("Set `max_retries` to 3 in the config")
7302        );
7303        assert!(content[0].marks.is_none());
7304    }
7305
7306    #[test]
7307    fn multiple_backtick_spans_roundtrip() {
7308        // Multiple backtick-delimited spans in a single text node
7309        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use `foo` and `bar` together"}]}]}"#;
7310        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7311        let jfm = adf_to_markdown(&adf).unwrap();
7312        let roundtripped = markdown_to_adf(&jfm).unwrap();
7313        let content = roundtripped.content[0].content.as_ref().unwrap();
7314        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7315        assert_eq!(
7316            content[0].text.as_deref(),
7317            Some("Use `foo` and `bar` together")
7318        );
7319        assert!(content[0].marks.is_none());
7320    }
7321
7322    #[test]
7323    fn lone_backtick_roundtrip() {
7324        // A single backtick that cannot form a code span should round-trip
7325        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use a ` character"}]}]}"#;
7326        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7327        let jfm = adf_to_markdown(&adf).unwrap();
7328        let roundtripped = markdown_to_adf(&jfm).unwrap();
7329        let content = roundtripped.content[0].content.as_ref().unwrap();
7330        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7331        assert_eq!(content[0].text.as_deref(), Some("Use a ` character"));
7332        assert!(content[0].marks.is_none());
7333    }
7334
7335    #[test]
7336    fn backtick_with_code_mark_roundtrip() {
7337        // Text that already has a code mark should preserve both the mark and content
7338        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"max_retries","marks":[{"type":"code"}]}]}]}"#;
7339        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7340        let jfm = adf_to_markdown(&adf).unwrap();
7341        assert_eq!(jfm.trim(), "`max_retries`");
7342        let roundtripped = markdown_to_adf(&jfm).unwrap();
7343        let content = roundtripped.content[0].content.as_ref().unwrap();
7344        let code_node = content.iter().find(|n| {
7345            n.marks
7346                .as_ref()
7347                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code"))
7348        });
7349        assert!(code_node.is_some(), "should have a code-marked node");
7350        assert_eq!(code_node.unwrap().text.as_deref(), Some("max_retries"));
7351    }
7352
7353    #[test]
7354    fn backtick_with_em_mark_roundtrip() {
7355        // Backtick inside em-marked text should preserve both
7356        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"use `cfg`","marks":[{"type":"em"}]}]}]}"#;
7357        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7358        let jfm = adf_to_markdown(&adf).unwrap();
7359        let roundtripped = markdown_to_adf(&jfm).unwrap();
7360        let content = roundtripped.content[0].content.as_ref().unwrap();
7361        let em_node = content.iter().find(|n| {
7362            n.marks
7363                .as_ref()
7364                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "em"))
7365        });
7366        assert!(em_node.is_some(), "should have an em-marked node");
7367        assert_eq!(em_node.unwrap().text.as_deref(), Some("use `cfg`"));
7368    }
7369
7370    #[test]
7371    fn escape_pipes_in_cell_unit() {
7372        assert_eq!(escape_pipes_in_cell("hello"), "hello");
7373        assert_eq!(escape_pipes_in_cell("a|b"), r"a\|b");
7374        assert_eq!(escape_pipes_in_cell("|"), r"\|");
7375        assert_eq!(escape_pipes_in_cell("|a|b|"), r"\|a\|b\|");
7376        assert_eq!(escape_pipes_in_cell(""), "");
7377        assert_eq!(
7378            escape_pipes_in_cell("`parser.decode[T|json]`"),
7379            r"`parser.decode[T\|json]`"
7380        );
7381    }
7382
7383    #[test]
7384    fn escape_backticks_unit() {
7385        assert_eq!(escape_backticks("hello"), "hello");
7386        assert_eq!(escape_backticks("`code`"), r"\`code\`");
7387        assert_eq!(escape_backticks("no ticks"), "no ticks");
7388        assert_eq!(escape_backticks("a ` b"), r"a \` b");
7389        assert_eq!(escape_backticks(""), "");
7390        assert_eq!(escape_backticks("`a` and `b`"), r"\`a\` and \`b\`");
7391    }
7392
7393    // ── Issue #477: backslash escaping ──────────────────────────────
7394
7395    #[test]
7396    fn backslash_in_text_roundtrip() {
7397        // The original reproducer from issue #477
7398        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"The path is C:\\Users\\admin\\file.txt"}]}]}"#;
7399        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7400        let jfm = adf_to_markdown(&adf).unwrap();
7401        let roundtripped = markdown_to_adf(&jfm).unwrap();
7402        let content = roundtripped.content[0].content.as_ref().unwrap();
7403        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7404        assert_eq!(
7405            content[0].text.as_deref(),
7406            Some(r"The path is C:\Users\admin\file.txt")
7407        );
7408    }
7409
7410    #[test]
7411    fn backslash_emitted_as_double_backslash() {
7412        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a\\b"}]}]}"#;
7413        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7414        let jfm = adf_to_markdown(&adf).unwrap();
7415        assert!(
7416            jfm.contains(r"a\\b"),
7417            "JFM should contain escaped backslash: {jfm}"
7418        );
7419    }
7420
7421    #[test]
7422    fn consecutive_backslashes_roundtrip() {
7423        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a\\\\b"}]}]}"#;
7424        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7425        let jfm = adf_to_markdown(&adf).unwrap();
7426        let roundtripped = markdown_to_adf(&jfm).unwrap();
7427        let content = roundtripped.content[0].content.as_ref().unwrap();
7428        assert_eq!(
7429            content[0].text.as_deref(),
7430            Some(r"a\\b"),
7431            "consecutive backslashes should survive round-trip"
7432        );
7433    }
7434
7435    #[test]
7436    fn backslash_with_strong_mark_roundtrip() {
7437        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\Users","marks":[{"type":"strong"}]}]}]}"#;
7438        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7439        let jfm = adf_to_markdown(&adf).unwrap();
7440        let roundtripped = markdown_to_adf(&jfm).unwrap();
7441        let content = roundtripped.content[0].content.as_ref().unwrap();
7442        let strong_node = content.iter().find(|n| {
7443            n.marks
7444                .as_ref()
7445                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
7446        });
7447        assert!(strong_node.is_some(), "should have a strong-marked node");
7448        assert_eq!(strong_node.unwrap().text.as_deref(), Some(r"C:\Users"));
7449    }
7450
7451    #[test]
7452    fn backslash_with_code_mark_not_escaped() {
7453        // Code marks emit content verbatim — backslashes should NOT be escaped
7454        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\Users","marks":[{"type":"code"}]}]}]}"#;
7455        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7456        let jfm = adf_to_markdown(&adf).unwrap();
7457        assert_eq!(jfm.trim(), r"`C:\Users`");
7458        let roundtripped = markdown_to_adf(&jfm).unwrap();
7459        let content = roundtripped.content[0].content.as_ref().unwrap();
7460        let code_node = content.iter().find(|n| {
7461            n.marks
7462                .as_ref()
7463                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code"))
7464        });
7465        assert!(code_node.is_some(), "should have a code-marked node");
7466        assert_eq!(code_node.unwrap().text.as_deref(), Some(r"C:\Users"));
7467    }
7468
7469    #[test]
7470    fn backslash_before_special_chars_roundtrip() {
7471        // Backslash before characters that are themselves escaped (* ` :)
7472        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"\\*not bold\\*"}]}]}"#;
7473        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7474        let jfm = adf_to_markdown(&adf).unwrap();
7475        let roundtripped = markdown_to_adf(&jfm).unwrap();
7476        let content = roundtripped.content[0].content.as_ref().unwrap();
7477        assert_eq!(
7478            content[0].text.as_deref(),
7479            Some(r"\*not bold\*"),
7480            "backslash before special char should survive round-trip"
7481        );
7482    }
7483
7484    #[test]
7485    fn backslash_and_newline_in_text_roundtrip() {
7486        // Text with both backslashes and embedded newlines
7487        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\path\nline2"}]}]}"#;
7488        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7489        let jfm = adf_to_markdown(&adf).unwrap();
7490        let roundtripped = markdown_to_adf(&jfm).unwrap();
7491        let content = roundtripped.content[0].content.as_ref().unwrap();
7492        assert_eq!(
7493            content[0].text.as_deref(),
7494            Some("C:\\path\nline2"),
7495            "backslash and newline should both survive round-trip"
7496        );
7497    }
7498
7499    #[test]
7500    fn lone_backslash_roundtrip() {
7501        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a \\ b"}]}]}"#;
7502        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7503        let jfm = adf_to_markdown(&adf).unwrap();
7504        let roundtripped = markdown_to_adf(&jfm).unwrap();
7505        let content = roundtripped.content[0].content.as_ref().unwrap();
7506        assert_eq!(content[0].text.as_deref(), Some(r"a \ b"));
7507    }
7508
7509    #[test]
7510    fn trailing_backslash_in_text_roundtrip() {
7511        // A trailing backslash in text content (not a hardBreak) should round-trip
7512        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"end\\"}]}]}"#;
7513        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7514        let jfm = adf_to_markdown(&adf).unwrap();
7515        let roundtripped = markdown_to_adf(&jfm).unwrap();
7516        let content = roundtripped.content[0].content.as_ref().unwrap();
7517        assert_eq!(
7518            content[0].text.as_deref(),
7519            Some(r"end\"),
7520            "trailing backslash should survive round-trip"
7521        );
7522    }
7523
7524    #[test]
7525    fn escape_bare_urls_unit() {
7526        assert_eq!(escape_bare_urls("hello"), "hello");
7527        assert_eq!(escape_bare_urls(""), "");
7528        assert_eq!(
7529            escape_bare_urls("https://example.com"),
7530            r"\https://example.com"
7531        );
7532        assert_eq!(
7533            escape_bare_urls("http://example.com"),
7534            r"\http://example.com"
7535        );
7536        assert_eq!(
7537            escape_bare_urls("see https://a.com and https://b.com"),
7538            r"see \https://a.com and \https://b.com"
7539        );
7540        // "http" without "://" is not a URL scheme — leave untouched
7541        assert_eq!(escape_bare_urls("http header"), "http header");
7542        assert_eq!(escape_bare_urls("https is secure"), "https is secure");
7543    }
7544
7545    #[test]
7546    fn heading_not_valid_without_space() {
7547        // "#Title" without space should be a paragraph, not heading
7548        let doc = markdown_to_adf("#Title").unwrap();
7549        assert_eq!(doc.content[0].node_type, "paragraph");
7550    }
7551
7552    #[test]
7553    fn heading_level_too_high() {
7554        // ####### (7 hashes) is not a valid heading
7555        let doc = markdown_to_adf("####### Not a heading").unwrap();
7556        assert_eq!(doc.content[0].node_type, "paragraph");
7557    }
7558
7559    #[test]
7560    fn empty_document() {
7561        let doc = markdown_to_adf("").unwrap();
7562        assert!(doc.content.is_empty());
7563    }
7564
7565    #[test]
7566    fn only_blank_lines() {
7567        let doc = markdown_to_adf("\n\n\n").unwrap();
7568        assert!(doc.content.is_empty());
7569    }
7570
7571    #[test]
7572    fn code_block_unterminated() {
7573        // Code block without closing fence
7574        let md = "```rust\nfn main() {}";
7575        let doc = markdown_to_adf(md).unwrap();
7576        assert_eq!(doc.content[0].node_type, "codeBlock");
7577    }
7578
7579    #[test]
7580    fn mixed_document() {
7581        let md = "# Title\n\nA paragraph.\n\n- Item\n\n```\ncode\n```\n\n> quote\n\n---\n\n1. numbered\n";
7582        let doc = markdown_to_adf(md).unwrap();
7583        let types: Vec<&str> = doc.content.iter().map(|n| n.node_type.as_str()).collect();
7584        assert_eq!(
7585            types,
7586            vec![
7587                "heading",
7588                "paragraph",
7589                "bulletList",
7590                "codeBlock",
7591                "blockquote",
7592                "rule",
7593                "orderedList",
7594            ]
7595        );
7596    }
7597
7598    // ── Additional adf_to_markdown tests ───────────────────────────────
7599
7600    #[test]
7601    fn adf_ordered_list_to_markdown() {
7602        let doc = AdfDocument {
7603            version: 1,
7604            doc_type: "doc".to_string(),
7605            content: vec![AdfNode::ordered_list(
7606                vec![
7607                    AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("First")])]),
7608                    AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("Second")])]),
7609                ],
7610                None,
7611            )],
7612        };
7613        let md = adf_to_markdown(&doc).unwrap();
7614        assert!(md.contains("1. First"));
7615        assert!(md.contains("2. Second"));
7616    }
7617
7618    #[test]
7619    fn adf_ordered_list_custom_start() {
7620        let doc = AdfDocument {
7621            version: 1,
7622            doc_type: "doc".to_string(),
7623            content: vec![AdfNode::ordered_list(
7624                vec![AdfNode::list_item(vec![AdfNode::paragraph(vec![
7625                    AdfNode::text("Third"),
7626                ])])],
7627                Some(3),
7628            )],
7629        };
7630        let md = adf_to_markdown(&doc).unwrap();
7631        assert!(md.contains("3. Third"));
7632    }
7633
7634    #[test]
7635    fn adf_blockquote_to_markdown() {
7636        let doc = AdfDocument {
7637            version: 1,
7638            doc_type: "doc".to_string(),
7639            content: vec![AdfNode::blockquote(vec![AdfNode::paragraph(vec![
7640                AdfNode::text("A quote"),
7641            ])])],
7642        };
7643        let md = adf_to_markdown(&doc).unwrap();
7644        assert!(md.contains("> A quote"));
7645    }
7646
7647    #[test]
7648    fn adf_table_to_markdown() {
7649        let doc = AdfDocument {
7650            version: 1,
7651            doc_type: "doc".to_string(),
7652            content: vec![AdfNode::table(vec![
7653                AdfNode::table_row(vec![
7654                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("Name")])]),
7655                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("Value")])]),
7656                ]),
7657                AdfNode::table_row(vec![
7658                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("a")])]),
7659                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("1")])]),
7660                ]),
7661            ])],
7662        };
7663        let md = adf_to_markdown(&doc).unwrap();
7664        assert!(md.contains("| Name | Value |"));
7665        assert!(md.contains("| --- | --- |"));
7666        assert!(md.contains("| a | 1 |"));
7667    }
7668
7669    #[test]
7670    fn adf_media_to_markdown() {
7671        let doc = AdfDocument {
7672            version: 1,
7673            doc_type: "doc".to_string(),
7674            content: vec![AdfNode::media_single(
7675                "https://example.com/img.png",
7676                Some("Alt"),
7677            )],
7678        };
7679        let md = adf_to_markdown(&doc).unwrap();
7680        assert!(md.contains("![Alt](https://example.com/img.png)"));
7681    }
7682
7683    #[test]
7684    fn adf_media_no_alt_to_markdown() {
7685        let doc = AdfDocument {
7686            version: 1,
7687            doc_type: "doc".to_string(),
7688            content: vec![AdfNode::media_single("https://example.com/img.png", None)],
7689        };
7690        let md = adf_to_markdown(&doc).unwrap();
7691        assert!(md.contains("![](https://example.com/img.png)"));
7692    }
7693
7694    #[test]
7695    fn adf_italic_to_markdown() {
7696        let doc = AdfDocument {
7697            version: 1,
7698            doc_type: "doc".to_string(),
7699            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7700                "emphasis",
7701                vec![AdfMark::em()],
7702            )])],
7703        };
7704        let md = adf_to_markdown(&doc).unwrap();
7705        assert_eq!(md.trim(), "*emphasis*");
7706    }
7707
7708    #[test]
7709    fn adf_strikethrough_to_markdown() {
7710        let doc = AdfDocument {
7711            version: 1,
7712            doc_type: "doc".to_string(),
7713            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7714                "deleted",
7715                vec![AdfMark::strike()],
7716            )])],
7717        };
7718        let md = adf_to_markdown(&doc).unwrap();
7719        assert_eq!(md.trim(), "~~deleted~~");
7720    }
7721
7722    #[test]
7723    fn adf_inline_code_to_markdown() {
7724        let doc = AdfDocument {
7725            version: 1,
7726            doc_type: "doc".to_string(),
7727            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7728                "code",
7729                vec![AdfMark::code()],
7730            )])],
7731        };
7732        let md = adf_to_markdown(&doc).unwrap();
7733        assert_eq!(md.trim(), "`code`");
7734    }
7735
7736    #[test]
7737    fn adf_code_with_link_to_markdown() {
7738        let doc = AdfDocument {
7739            version: 1,
7740            doc_type: "doc".to_string(),
7741            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7742                "func",
7743                vec![AdfMark::code(), AdfMark::link("https://example.com")],
7744            )])],
7745        };
7746        let md = adf_to_markdown(&doc).unwrap();
7747        assert_eq!(md.trim(), "[`func`](https://example.com)");
7748    }
7749
7750    #[test]
7751    fn adf_bold_italic_to_markdown() {
7752        let doc = AdfDocument {
7753            version: 1,
7754            doc_type: "doc".to_string(),
7755            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7756                "both",
7757                vec![AdfMark::strong(), AdfMark::em()],
7758            )])],
7759        };
7760        let md = adf_to_markdown(&doc).unwrap();
7761        assert_eq!(md.trim(), "***both***");
7762    }
7763
7764    #[test]
7765    fn adf_bold_link_to_markdown() {
7766        let doc = AdfDocument {
7767            version: 1,
7768            doc_type: "doc".to_string(),
7769            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7770                "bold link",
7771                vec![AdfMark::strong(), AdfMark::link("https://example.com")],
7772            )])],
7773        };
7774        let md = adf_to_markdown(&doc).unwrap();
7775        assert_eq!(md.trim(), "**[bold link](https://example.com)**");
7776    }
7777
7778    #[test]
7779    fn adf_strikethrough_bold_to_markdown() {
7780        let doc = AdfDocument {
7781            version: 1,
7782            doc_type: "doc".to_string(),
7783            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7784                "struck",
7785                vec![AdfMark::strike(), AdfMark::strong()],
7786            )])],
7787        };
7788        let md = adf_to_markdown(&doc).unwrap();
7789        assert_eq!(md.trim(), "~~**struck**~~");
7790    }
7791
7792    #[test]
7793    fn adf_hard_break_to_markdown() {
7794        let doc = AdfDocument {
7795            version: 1,
7796            doc_type: "doc".to_string(),
7797            content: vec![AdfNode::paragraph(vec![
7798                AdfNode::text("Line 1"),
7799                AdfNode::hard_break(),
7800                AdfNode::text("Line 2"),
7801            ])],
7802        };
7803        let md = adf_to_markdown(&doc).unwrap();
7804        assert!(md.contains("Line 1\\\n  Line 2"));
7805    }
7806
7807    #[test]
7808    #[test]
7809    fn adf_unsupported_inline_to_markdown() {
7810        let doc = AdfDocument {
7811            version: 1,
7812            doc_type: "doc".to_string(),
7813            content: vec![AdfNode::paragraph(vec![AdfNode {
7814                node_type: "unknownInline".to_string(),
7815                attrs: None,
7816                content: None,
7817                text: None,
7818                marks: None,
7819                local_id: None,
7820                parameters: None,
7821            }])],
7822        };
7823        let md = adf_to_markdown(&doc).unwrap();
7824        assert!(md.contains("<!-- unsupported inline: unknownInline -->"));
7825    }
7826
7827    // ── mediaInline tests (issue #476) ─────────────────────────────────
7828
7829    #[test]
7830    fn adf_media_inline_to_markdown() {
7831        let doc = AdfDocument {
7832            version: 1,
7833            doc_type: "doc".to_string(),
7834            content: vec![AdfNode::paragraph(vec![
7835                AdfNode::text("see "),
7836                AdfNode::media_inline(serde_json::json!({
7837                    "type": "image",
7838                    "id": "abcdef01-2345-6789-abcd-abcdef012345",
7839                    "collection": "contentId-111111",
7840                    "width": 200,
7841                    "height": 100
7842                })),
7843                AdfNode::text(" for details"),
7844            ])],
7845        };
7846        let md = adf_to_markdown(&doc).unwrap();
7847        assert!(md.contains(":media-inline[]{"), "got: {md}");
7848        assert!(md.contains("type=image"));
7849        assert!(md.contains("id=abcdef01-2345-6789-abcd-abcdef012345"));
7850        assert!(md.contains("collection=contentId-111111"));
7851        assert!(md.contains("width=200"));
7852        assert!(md.contains("height=100"));
7853        assert!(!md.contains("<!-- unsupported inline"));
7854    }
7855
7856    #[test]
7857    fn media_inline_round_trip() {
7858        let doc = AdfDocument {
7859            version: 1,
7860            doc_type: "doc".to_string(),
7861            content: vec![AdfNode::paragraph(vec![
7862                AdfNode::text("see "),
7863                AdfNode::media_inline(serde_json::json!({
7864                    "type": "image",
7865                    "id": "abcdef01-2345-6789-abcd-abcdef012345",
7866                    "collection": "contentId-111111",
7867                    "width": 200,
7868                    "height": 100
7869                })),
7870                AdfNode::text(" for details"),
7871            ])],
7872        };
7873        let md = adf_to_markdown(&doc).unwrap();
7874        let rt = markdown_to_adf(&md).unwrap();
7875
7876        let content = rt.content[0].content.as_ref().unwrap();
7877        assert_eq!(content[0].text.as_deref(), Some("see "));
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!(content[2].text.as_deref(), Some(" for details"));
7886    }
7887
7888    #[test]
7889    fn media_inline_external_url_round_trip() {
7890        let doc = AdfDocument {
7891            version: 1,
7892            doc_type: "doc".to_string(),
7893            content: vec![AdfNode::paragraph(vec![AdfNode::media_inline(
7894                serde_json::json!({
7895                    "type": "external",
7896                    "url": "https://example.com/image.png",
7897                    "alt": "example",
7898                    "width": 400,
7899                    "height": 300
7900                }),
7901            )])],
7902        };
7903        let md = adf_to_markdown(&doc).unwrap();
7904        let rt = markdown_to_adf(&md).unwrap();
7905
7906        let content = rt.content[0].content.as_ref().unwrap();
7907        assert_eq!(content[0].node_type, "mediaInline");
7908        let attrs = content[0].attrs.as_ref().unwrap();
7909        assert_eq!(attrs["type"], "external");
7910        assert_eq!(attrs["url"], "https://example.com/image.png");
7911        assert_eq!(attrs["alt"], "example");
7912        assert_eq!(attrs["width"], 400);
7913        assert_eq!(attrs["height"], 300);
7914    }
7915
7916    #[test]
7917    fn media_inline_minimal_attrs() {
7918        let doc = AdfDocument {
7919            version: 1,
7920            doc_type: "doc".to_string(),
7921            content: vec![AdfNode::paragraph(vec![AdfNode::media_inline(
7922                serde_json::json!({"type": "file", "id": "abc-123"}),
7923            )])],
7924        };
7925        let md = adf_to_markdown(&doc).unwrap();
7926        let rt = markdown_to_adf(&md).unwrap();
7927
7928        let content = rt.content[0].content.as_ref().unwrap();
7929        assert_eq!(content[0].node_type, "mediaInline");
7930        let attrs = content[0].attrs.as_ref().unwrap();
7931        assert_eq!(attrs["type"], "file");
7932        assert_eq!(attrs["id"], "abc-123");
7933    }
7934
7935    #[test]
7936    fn media_inline_from_issue_476_reproducer() {
7937        // Exact reproducer from issue #476
7938        let adf_json: serde_json::Value = serde_json::json!({
7939            "type": "doc",
7940            "version": 1,
7941            "content": [
7942                {
7943                    "type": "paragraph",
7944                    "content": [
7945                        {"type": "text", "text": "see "},
7946                        {
7947                            "type": "mediaInline",
7948                            "attrs": {
7949                                "collection": "contentId-111111",
7950                                "height": 100,
7951                                "id": "abcdef01-2345-6789-abcd-abcdef012345",
7952                                "localId": "aabbccdd-1234-5678-abcd-aabbccdd1234",
7953                                "type": "image",
7954                                "width": 200
7955                            }
7956                        },
7957                        {"type": "text", "text": " for details"}
7958                    ]
7959                }
7960            ]
7961        });
7962        let doc: AdfDocument = serde_json::from_value(adf_json).unwrap();
7963        let md = adf_to_markdown(&doc).unwrap();
7964        assert!(
7965            !md.contains("<!-- unsupported inline"),
7966            "mediaInline should not be unsupported; got: {md}"
7967        );
7968
7969        let rt = markdown_to_adf(&md).unwrap();
7970        let content = rt.content[0].content.as_ref().unwrap();
7971        assert_eq!(content[1].node_type, "mediaInline");
7972        let attrs = content[1].attrs.as_ref().unwrap();
7973        assert_eq!(attrs["type"], "image");
7974        assert_eq!(attrs["id"], "abcdef01-2345-6789-abcd-abcdef012345");
7975        assert_eq!(attrs["collection"], "contentId-111111");
7976        assert_eq!(attrs["width"], 200);
7977        assert_eq!(attrs["height"], 100);
7978        assert_eq!(attrs["localId"], "aabbccdd-1234-5678-abcd-aabbccdd1234");
7979    }
7980
7981    #[test]
7982    fn emoji_shortcode() {
7983        let doc = markdown_to_adf("Hello :wave: world").unwrap();
7984        let content = doc.content[0].content.as_ref().unwrap();
7985        assert_eq!(content[0].text.as_deref(), Some("Hello "));
7986        assert_eq!(content[1].node_type, "emoji");
7987        assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":wave:");
7988        assert_eq!(content[2].text.as_deref(), Some(" world"));
7989    }
7990
7991    #[test]
7992    fn adf_emoji_to_markdown() {
7993        let doc = AdfDocument {
7994            version: 1,
7995            doc_type: "doc".to_string(),
7996            content: vec![AdfNode::paragraph(vec![AdfNode::emoji("thumbsup")])],
7997        };
7998        let md = adf_to_markdown(&doc).unwrap();
7999        assert!(md.contains(":thumbsup:"));
8000    }
8001
8002    #[test]
8003    fn adf_emoji_with_colon_prefix_to_markdown() {
8004        // JIRA stores shortName as ":thumbsup:" with colons
8005        let doc = AdfDocument {
8006            version: 1,
8007            doc_type: "doc".to_string(),
8008            content: vec![AdfNode::paragraph(vec![AdfNode {
8009                node_type: "emoji".to_string(),
8010                attrs: Some(serde_json::json!({"shortName": ":thumbsup:"})),
8011                content: None,
8012                text: None,
8013                marks: None,
8014                local_id: None,
8015                parameters: None,
8016            }])],
8017        };
8018        let md = adf_to_markdown(&doc).unwrap();
8019        assert!(md.contains(":thumbsup:"));
8020        // Should not produce ::thumbsup:: (double colons)
8021        assert!(!md.contains("::thumbsup::"));
8022    }
8023
8024    #[test]
8025    fn round_trip_emoji() {
8026        let md = "Hello :wave: world\n";
8027        let doc = markdown_to_adf(md).unwrap();
8028        let result = adf_to_markdown(&doc).unwrap();
8029        assert!(result.contains(":wave:"));
8030    }
8031
8032    #[test]
8033    fn emoji_with_id_and_text_round_trips() {
8034        let doc = AdfDocument {
8035            version: 1,
8036            doc_type: "doc".to_string(),
8037            content: vec![AdfNode::paragraph(vec![AdfNode {
8038                node_type: "emoji".to_string(),
8039                attrs: Some(
8040                    serde_json::json!({"shortName": ":check_mark:", "id": "2705", "text": "✅"}),
8041                ),
8042                content: None,
8043                text: None,
8044                marks: None,
8045                local_id: None,
8046                parameters: None,
8047            }])],
8048        };
8049        let md = adf_to_markdown(&doc).unwrap();
8050        assert!(md.contains(":check_mark:"), "shortcode present: {md}");
8051        assert!(md.contains("id="), "id attr present: {md}");
8052        assert!(md.contains("text="), "text attr present: {md}");
8053
8054        // Round-trip back to ADF
8055        let round_tripped = markdown_to_adf(&md).unwrap();
8056        let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
8057        let attrs = emoji.attrs.as_ref().unwrap();
8058        assert_eq!(attrs["shortName"], ":check_mark:");
8059        assert_eq!(attrs["id"], "2705");
8060        assert_eq!(attrs["text"], "✅");
8061    }
8062
8063    #[test]
8064    fn emoji_without_extra_attrs_still_works() {
8065        let md = "Hello :wave: world\n";
8066        let doc = markdown_to_adf(md).unwrap();
8067        let emoji = &doc.content[0].content.as_ref().unwrap()[1];
8068        assert_eq!(emoji.attrs.as_ref().unwrap()["shortName"], ":wave:");
8069        // No id or text attrs when not provided
8070        assert!(emoji.attrs.as_ref().unwrap().get("id").is_none());
8071    }
8072
8073    #[test]
8074    fn emoji_shortname_preserves_colons_round_trip() {
8075        // Issue #362: emoji shortName colons stripped during round-trip
8076        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8077          {"type":"emoji","attrs":{"shortName":":cross_mark:","id":"atlassian-cross_mark","text":"❌"}}
8078        ]}]}"#;
8079        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8080
8081        // ADF → markdown → ADF round-trip
8082        let md = adf_to_markdown(&doc).unwrap();
8083        let round_tripped = markdown_to_adf(&md).unwrap();
8084        let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
8085        let attrs = emoji.attrs.as_ref().unwrap();
8086        assert_eq!(
8087            attrs["shortName"], ":cross_mark:",
8088            "shortName should preserve colons, got: {}",
8089            attrs["shortName"]
8090        );
8091        assert_eq!(attrs["id"], "atlassian-cross_mark");
8092        assert_eq!(attrs["text"], "❌");
8093    }
8094
8095    #[test]
8096    fn emoji_shortname_without_colons_preserved() {
8097        // Issue #379: shortName without colons should not gain colons
8098        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8099          {"type":"emoji","attrs":{"shortName":"white_check_mark","id":"2705","text":"✅"}}
8100        ]}]}"#;
8101        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8102        let md = adf_to_markdown(&doc).unwrap();
8103        let round_tripped = markdown_to_adf(&md).unwrap();
8104        let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
8105        let attrs = emoji.attrs.as_ref().unwrap();
8106        assert_eq!(
8107            attrs["shortName"], "white_check_mark",
8108            "shortName without colons should stay without colons, got: {}",
8109            attrs["shortName"]
8110        );
8111    }
8112
8113    #[test]
8114    fn colon_in_text_not_emoji() {
8115        // A lone colon should not trigger emoji parsing
8116        let doc = markdown_to_adf("Time is 10:30 today").unwrap();
8117        let content = doc.content[0].content.as_ref().unwrap();
8118        assert_eq!(content.len(), 1);
8119        assert_eq!(content[0].node_type, "text");
8120    }
8121
8122    #[test]
8123    fn text_with_shortcode_pattern_round_trips_as_text() {
8124        // Issue #450: `:fire:` in a text node must not become an emoji node
8125        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Alert :fire: triggered on pod:pod42"}]}]}"#;
8126        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8127
8128        let md = adf_to_markdown(&doc).unwrap();
8129        let round_tripped = markdown_to_adf(&md).unwrap();
8130        let content = round_tripped.content[0].content.as_ref().unwrap();
8131
8132        assert_eq!(
8133            content.len(),
8134            1,
8135            "should be a single text node, got: {content:?}"
8136        );
8137        assert_eq!(content[0].node_type, "text");
8138        assert_eq!(
8139            content[0].text.as_deref().unwrap(),
8140            "Alert :fire: triggered on pod:pod42"
8141        );
8142    }
8143
8144    #[test]
8145    fn double_colon_pattern_round_trips_as_text() {
8146        // Issue #450: `::Active::` should not be parsed as emoji `:Active:`
8147        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Status::Active::Running"}]}]}"#;
8148        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8149
8150        let md = adf_to_markdown(&doc).unwrap();
8151        let round_tripped = markdown_to_adf(&md).unwrap();
8152        let content = round_tripped.content[0].content.as_ref().unwrap();
8153
8154        assert_eq!(
8155            content.len(),
8156            1,
8157            "should be a single text node, got: {content:?}"
8158        );
8159        assert_eq!(content[0].node_type, "text");
8160        assert_eq!(
8161            content[0].text.as_deref().unwrap(),
8162            "Status::Active::Running"
8163        );
8164    }
8165
8166    #[test]
8167    fn real_emoji_node_still_round_trips() {
8168        // Ensure actual emoji ADF nodes still work after the escaping fix
8169        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8170          {"type":"text","text":"Hello "},
8171          {"type":"emoji","attrs":{"shortName":":fire:","id":"1f525","text":"🔥"}},
8172          {"type":"text","text":" world"}
8173        ]}]}"#;
8174        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8175
8176        let md = adf_to_markdown(&doc).unwrap();
8177        let round_tripped = markdown_to_adf(&md).unwrap();
8178        let content = round_tripped.content[0].content.as_ref().unwrap();
8179
8180        // Should have: text("Hello ") + emoji(:fire:) + text(" world")
8181        assert_eq!(content.len(), 3, "should have 3 nodes: {content:?}");
8182        assert_eq!(content[0].text.as_deref(), Some("Hello "));
8183        assert_eq!(content[1].node_type, "emoji");
8184        assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":fire:");
8185        assert_eq!(content[2].text.as_deref(), Some(" world"));
8186    }
8187
8188    #[test]
8189    fn combined_emoji_shortname_round_trips_as_single_node() {
8190        // Issue #576: an emoji node whose shortName is a combination of two
8191        // shortcodes (e.g. ":slightly_smiling_face::bow:") must survive the
8192        // ADF → markdown → ADF round-trip as a single emoji node rather than
8193        // being split into two.
8194        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8195          {"type":"text","text":"Thanks for the help "},
8196          {"type":"emoji","attrs":{"shortName":":slightly_smiling_face::bow:","id":"","text":""}}
8197        ]}]}"#;
8198        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8199
8200        let md = adf_to_markdown(&doc).unwrap();
8201        let round_tripped = markdown_to_adf(&md).unwrap();
8202        let content = round_tripped.content[0].content.as_ref().unwrap();
8203
8204        assert_eq!(
8205            content.len(),
8206            2,
8207            "should have text + single combined emoji: {content:?}"
8208        );
8209        assert_eq!(content[0].text.as_deref(), Some("Thanks for the help "));
8210        assert_eq!(content[1].node_type, "emoji");
8211        let attrs = content[1].attrs.as_ref().unwrap();
8212        assert_eq!(attrs["shortName"], ":slightly_smiling_face::bow:");
8213        assert_eq!(attrs["id"], "");
8214        assert_eq!(attrs["text"], "");
8215    }
8216
8217    #[test]
8218    fn triple_combined_emoji_shortname_round_trips_as_single_node() {
8219        // Three-part combined shortName must also survive round-trip.
8220        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8221          {"type":"emoji","attrs":{"shortName":":a::b::c:","id":"x","text":""}}
8222        ]}]}"#;
8223        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8224
8225        let md = adf_to_markdown(&doc).unwrap();
8226        let round_tripped = markdown_to_adf(&md).unwrap();
8227        let content = round_tripped.content[0].content.as_ref().unwrap();
8228
8229        assert_eq!(content.len(), 1, "should be single emoji: {content:?}");
8230        assert_eq!(content[0].node_type, "emoji");
8231        let attrs = content[0].attrs.as_ref().unwrap();
8232        assert_eq!(attrs["shortName"], ":a::b::c:");
8233        assert_eq!(attrs["id"], "x");
8234    }
8235
8236    #[test]
8237    fn consecutive_emojis_remain_separate_nodes() {
8238        // Two independent emoji nodes (each with their own directive) must
8239        // remain two separate nodes — the combined-chain logic must not
8240        // swallow the second emoji.
8241        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8242          {"type":"emoji","attrs":{"shortName":":fire:","id":"1f525","text":"🔥"}},
8243          {"type":"emoji","attrs":{"shortName":":water:","id":"1f4a7","text":"💧"}}
8244        ]}]}"#;
8245        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8246
8247        let md = adf_to_markdown(&doc).unwrap();
8248        let round_tripped = markdown_to_adf(&md).unwrap();
8249        let content = round_tripped.content[0].content.as_ref().unwrap();
8250
8251        assert_eq!(content.len(), 2, "should be two emoji nodes: {content:?}");
8252        assert_eq!(content[0].node_type, "emoji");
8253        assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":fire:");
8254        assert_eq!(content[1].node_type, "emoji");
8255        assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":water:");
8256    }
8257
8258    #[test]
8259    fn adjacent_shortcodes_without_directive_parse_as_two_emojis() {
8260        // Raw markdown with two adjacent shortcodes and no directive should
8261        // still parse as two separate emoji nodes.
8262        let md = ":fire::water:";
8263        let doc = markdown_to_adf(md).unwrap();
8264        let content = doc.content[0].content.as_ref().unwrap();
8265
8266        assert_eq!(content.len(), 2, "should be two emojis: {content:?}");
8267        assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":fire:");
8268        assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":water:");
8269    }
8270
8271    #[test]
8272    fn combined_emoji_shortname_preserves_local_id() {
8273        // The directive's localId should be preserved when the chain is
8274        // collapsed into a single emoji node.
8275        let md = r#":a::b:{shortName=":a::b:" id="x" text="y" localId="abc"}"#;
8276        let doc = markdown_to_adf(md).unwrap();
8277        let content = doc.content[0].content.as_ref().unwrap();
8278
8279        assert_eq!(content.len(), 1, "should be single emoji: {content:?}");
8280        let attrs = content[0].attrs.as_ref().unwrap();
8281        assert_eq!(attrs["shortName"], ":a::b:");
8282        assert_eq!(attrs["id"], "x");
8283        assert_eq!(attrs["text"], "y");
8284        assert_eq!(attrs["localId"], "abc");
8285    }
8286
8287    #[test]
8288    fn text_shortcode_with_marks_round_trips() {
8289        // Bold text containing an emoji-like shortcode should round-trip as bold text
8290        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8291          {"type":"text","text":"Alert :fire: triggered","marks":[{"type":"strong"}]}
8292        ]}]}"#;
8293        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8294
8295        let md = adf_to_markdown(&doc).unwrap();
8296        let round_tripped = markdown_to_adf(&md).unwrap();
8297        let content = round_tripped.content[0].content.as_ref().unwrap();
8298
8299        assert_eq!(
8300            content.len(),
8301            1,
8302            "should be single bold text node: {content:?}"
8303        );
8304        assert_eq!(content[0].node_type, "text");
8305        assert_eq!(
8306            content[0].text.as_deref().unwrap(),
8307            "Alert :fire: triggered"
8308        );
8309        assert!(content[0]
8310            .marks
8311            .as_ref()
8312            .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong")));
8313    }
8314
8315    #[test]
8316    fn mixed_emoji_node_and_text_shortcode_round_trips() {
8317        // Real emoji node adjacent to text containing a shortcode-like pattern
8318        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8319          {"type":"emoji","attrs":{"shortName":":wave:","id":"1f44b","text":"👋"}},
8320          {"type":"text","text":" says :hello: to you"}
8321        ]}]}"#;
8322        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8323
8324        let md = adf_to_markdown(&doc).unwrap();
8325        let round_tripped = markdown_to_adf(&md).unwrap();
8326        let content = round_tripped.content[0].content.as_ref().unwrap();
8327
8328        // Should be: emoji(:wave:) + text(" says :hello: to you")
8329        assert_eq!(content.len(), 2, "should have 2 nodes: {content:?}");
8330        assert_eq!(content[0].node_type, "emoji");
8331        assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":wave:");
8332        assert_eq!(content[1].node_type, "text");
8333        assert_eq!(content[1].text.as_deref().unwrap(), " says :hello: to you");
8334    }
8335
8336    #[test]
8337    fn code_block_with_shortcode_pattern_round_trips() {
8338        // Issue #552: Code block content containing `::Name::` patterns must not
8339        // be re-parsed as emoji shortcodes.
8340        let adf_json = r#"{"version":1,"type":"doc","content":[
8341          {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8342            {"type":"text","text":"module Foo::Bar::Baz\n  def hello\n    puts 'world'\n  end\nend"}
8343          ]}
8344        ]}"#;
8345        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8346
8347        let md = adf_to_markdown(&doc).unwrap();
8348        let round_tripped = markdown_to_adf(&md).unwrap();
8349
8350        assert_eq!(
8351            round_tripped.content.len(),
8352            1,
8353            "should be a single codeBlock"
8354        );
8355        let cb = &round_tripped.content[0];
8356        assert_eq!(cb.node_type, "codeBlock");
8357        let content = cb.content.as_ref().expect("codeBlock content");
8358        assert_eq!(
8359            content.len(),
8360            1,
8361            "should be a single text node: {content:?}"
8362        );
8363        assert_eq!(content[0].node_type, "text");
8364        assert_eq!(
8365            content[0].text.as_deref().unwrap(),
8366            "module Foo::Bar::Baz\n  def hello\n    puts 'world'\n  end\nend"
8367        );
8368        assert!(
8369            content.iter().all(|n| n.node_type != "emoji"),
8370            "no emoji nodes should be present, got: {content:?}"
8371        );
8372    }
8373
8374    #[test]
8375    fn code_block_with_exact_zendesk_shortcode_pattern_round_trips() {
8376        // Issue #552: Use the exact pattern from the bug report.
8377        let adf_json = r#"{"version":1,"type":"doc","content":[
8378          {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8379            {"type":"text","text":"class ZBC::Zendesk::PlanType::Professional < Base"}
8380          ]}
8381        ]}"#;
8382        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8383
8384        let md = adf_to_markdown(&doc).unwrap();
8385        let round_tripped = markdown_to_adf(&md).unwrap();
8386
8387        let cb = &round_tripped.content[0];
8388        assert_eq!(cb.node_type, "codeBlock");
8389        let content = cb.content.as_ref().expect("codeBlock content");
8390        assert_eq!(content.len(), 1, "should be a single text node");
8391        assert_eq!(
8392            content[0].text.as_deref().unwrap(),
8393            "class ZBC::Zendesk::PlanType::Professional < Base"
8394        );
8395    }
8396
8397    #[test]
8398    fn code_block_with_literal_shortcode_round_trips() {
8399        // Issue #552: Content that is exactly a shortcode (`:fire:`) inside a
8400        // code block should survive the round-trip as literal text, not emoji.
8401        let adf_json = r#"{"version":1,"type":"doc","content":[
8402          {"type":"codeBlock","attrs":{"language":"text"},"content":[
8403            {"type":"text","text":":fire: :wave: :thumbsup:"}
8404          ]}
8405        ]}"#;
8406        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8407
8408        let md = adf_to_markdown(&doc).unwrap();
8409        let round_tripped = markdown_to_adf(&md).unwrap();
8410
8411        let cb = &round_tripped.content[0];
8412        assert_eq!(cb.node_type, "codeBlock");
8413        let content = cb.content.as_ref().expect("codeBlock content");
8414        assert_eq!(
8415            content.len(),
8416            1,
8417            "should be a single text node: {content:?}"
8418        );
8419        assert_eq!(content[0].node_type, "text");
8420        assert_eq!(
8421            content[0].text.as_deref().unwrap(),
8422            ":fire: :wave: :thumbsup:"
8423        );
8424    }
8425
8426    #[test]
8427    fn inline_code_with_shortcode_pattern_round_trips() {
8428        // Issue #552: Inline code containing `::Name::` patterns must not be
8429        // re-parsed as emoji shortcodes.
8430        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8431          {"type":"text","text":"See "},
8432          {"type":"text","text":"Foo::Bar::Baz","marks":[{"type":"code"}]},
8433          {"type":"text","text":" for details"}
8434        ]}]}"#;
8435        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8436
8437        let md = adf_to_markdown(&doc).unwrap();
8438        let round_tripped = markdown_to_adf(&md).unwrap();
8439        let content = round_tripped.content[0].content.as_ref().unwrap();
8440
8441        assert_eq!(content.len(), 3, "should have 3 text nodes: {content:?}");
8442        assert_eq!(content[0].text.as_deref(), Some("See "));
8443        assert_eq!(content[1].text.as_deref(), Some("Foo::Bar::Baz"));
8444        assert!(content[1]
8445            .marks
8446            .as_ref()
8447            .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
8448        assert_eq!(content[2].text.as_deref(), Some(" for details"));
8449        assert!(
8450            content.iter().all(|n| n.node_type != "emoji"),
8451            "no emoji nodes should be present"
8452        );
8453    }
8454
8455    #[test]
8456    fn inline_code_with_literal_shortcode_round_trips() {
8457        // Issue #552: Inline code whose content is exactly a shortcode must be
8458        // preserved as code, not converted to an emoji.
8459        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8460          {"type":"text","text":":fire:","marks":[{"type":"code"}]}
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        let content = round_tripped.content[0].content.as_ref().unwrap();
8467
8468        assert_eq!(
8469            content.len(),
8470            1,
8471            "should be a single code node: {content:?}"
8472        );
8473        assert_eq!(content[0].node_type, "text");
8474        assert_eq!(content[0].text.as_deref(), Some(":fire:"));
8475        assert!(content[0]
8476            .marks
8477            .as_ref()
8478            .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
8479    }
8480
8481    #[test]
8482    fn code_block_in_list_with_shortcode_pattern_round_trips() {
8483        // Issue #552: A code block containing shortcode-like patterns nested in
8484        // a list should still survive round-trip without emoji corruption.
8485        let adf_json = r#"{"version":1,"type":"doc","content":[
8486          {"type":"bulletList","content":[
8487            {"type":"listItem","content":[
8488              {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8489                {"type":"text","text":"Foo::Bar::Baz"}
8490              ]}
8491            ]}
8492          ]}
8493        ]}"#;
8494        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8495
8496        let md = adf_to_markdown(&doc).unwrap();
8497        let round_tripped = markdown_to_adf(&md).unwrap();
8498
8499        let list = &round_tripped.content[0];
8500        assert_eq!(list.node_type, "bulletList");
8501        let item = &list.content.as_ref().unwrap()[0];
8502        assert_eq!(item.node_type, "listItem");
8503        let cb = &item.content.as_ref().unwrap()[0];
8504        assert_eq!(cb.node_type, "codeBlock");
8505        let cb_content = cb.content.as_ref().unwrap();
8506        assert_eq!(cb_content[0].text.as_deref(), Some("Foo::Bar::Baz"));
8507        assert_eq!(cb_content[0].node_type, "text");
8508    }
8509
8510    #[test]
8511    fn code_block_with_unicode_shortcode_pattern_round_trips() {
8512        // Issue #552: Code block content with non-ASCII colon-delimited words
8513        // (e.g. CJK or accented characters) must round-trip without splitting
8514        // or emoji corruption.
8515        let adf_json = r#"{"version":1,"type":"doc","content":[
8516          {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8517            {"type":"text","text":"class ZBC::配置::Production < Base"}
8518          ]}
8519        ]}"#;
8520        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8521
8522        let md = adf_to_markdown(&doc).unwrap();
8523        let round_tripped = markdown_to_adf(&md).unwrap();
8524
8525        let cb = &round_tripped.content[0];
8526        assert_eq!(cb.node_type, "codeBlock");
8527        let content = cb.content.as_ref().expect("codeBlock content");
8528        assert_eq!(content.len(), 1);
8529        assert_eq!(
8530            content[0].text.as_deref().unwrap(),
8531            "class ZBC::配置::Production < Base"
8532        );
8533    }
8534
8535    #[test]
8536    fn list_item_hardbreak_then_code_block_round_trips() {
8537        // Issue #552: A list item whose first paragraph ends with a hardBreak
8538        // followed by a codeBlock must round-trip correctly.  Previously, the
8539        // hardBreak's `\` continuation swallowed the 2-space-indented code
8540        // fence line, turning the whole block into a paragraph where `::Bar::`
8541        // was re-parsed as an emoji.
8542        let adf_json = r#"{"version":1,"type":"doc","content":[
8543          {"type":"bulletList","content":[
8544            {"type":"listItem","content":[
8545              {"type":"paragraph","content":[
8546                {"type":"text","text":"Consider removing this check."},
8547                {"type":"hardBreak"}
8548              ]},
8549              {"type":"codeBlock","content":[
8550                {"type":"text","text":"x = Foo::Bar::Baz.new"}
8551              ]}
8552            ]}
8553          ]}
8554        ]}"#;
8555        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8556
8557        let md = adf_to_markdown(&doc).unwrap();
8558        let round_tripped = markdown_to_adf(&md).unwrap();
8559
8560        let list = &round_tripped.content[0];
8561        assert_eq!(list.node_type, "bulletList");
8562        let item = &list.content.as_ref().unwrap()[0];
8563        assert_eq!(item.node_type, "listItem");
8564        let item_content = item.content.as_ref().unwrap();
8565        assert_eq!(
8566            item_content.len(),
8567            2,
8568            "listItem should have paragraph + codeBlock, got: {item_content:?}"
8569        );
8570        assert_eq!(item_content[0].node_type, "paragraph");
8571        assert_eq!(item_content[1].node_type, "codeBlock");
8572
8573        // The code block text must survive verbatim — no emoji splitting.
8574        let cb_content = item_content[1].content.as_ref().unwrap();
8575        assert_eq!(cb_content[0].node_type, "text");
8576        assert_eq!(
8577            cb_content[0].text.as_deref().unwrap(),
8578            "x = Foo::Bar::Baz.new"
8579        );
8580
8581        // And there should be no emoji node anywhere in the tree.
8582        assert!(
8583            item_content
8584                .iter()
8585                .flat_map(|n| n.content.iter().flat_map(|c| c.iter()))
8586                .all(|n| n.node_type != "emoji"),
8587            "no emoji nodes should be present, got: {item_content:?}"
8588        );
8589    }
8590
8591    #[test]
8592    fn list_item_hardbreak_then_nested_list_still_works() {
8593        // Ensure the hardBreak continuation fix doesn't break nested list
8594        // handling — an indented `- item` line after a hardBreak is still a
8595        // nested list, not a code fence.
8596        let md = "- first\\\n  continuation text\\\n  - nested item\n";
8597        let doc = markdown_to_adf(md).unwrap();
8598        let list = &doc.content[0];
8599        assert_eq!(list.node_type, "bulletList");
8600        let item = &list.content.as_ref().unwrap()[0];
8601        // First paragraph should contain the continuation text joined via hardBreaks
8602        let item_content = item.content.as_ref().unwrap();
8603        let para = &item_content[0];
8604        assert_eq!(para.node_type, "paragraph");
8605        let para_nodes = para.content.as_ref().unwrap();
8606        assert!(
8607            para_nodes
8608                .iter()
8609                .any(|n| n.text.as_deref() == Some("continuation text")),
8610            "continuation text should survive: {para_nodes:?}"
8611        );
8612    }
8613
8614    #[test]
8615    fn list_item_hardbreak_then_image_still_works() {
8616        // Regression check for issue #490: the image-skip behaviour in
8617        // collect_hardbreak_continuations must still hold after the code-fence
8618        // fix.  The `![](url)` line must remain a block-level mediaSingle, not
8619        // be swallowed into the paragraph.
8620        let md = "- first\\\n  ![](https://example.com/x.png){type=file id=x}\n";
8621        let doc = markdown_to_adf(md).unwrap();
8622        let list = &doc.content[0];
8623        let item = &list.content.as_ref().unwrap()[0];
8624        let item_content = item.content.as_ref().unwrap();
8625        // The image should be a sibling block, not part of the first paragraph.
8626        assert!(
8627            item_content.iter().any(|n| n.node_type == "mediaSingle"),
8628            "mediaSingle should still be a block-level sibling, got: {item_content:?}"
8629        );
8630    }
8631
8632    #[test]
8633    fn list_item_hardbreak_then_container_directive_round_trips() {
8634        // Issue #552: Same hardBreak-swallows-block-siblings bug class — a
8635        // container directive (`:::panel`) after a hardBreak must also not be
8636        // consumed as a continuation line.
8637        let adf_json = r#"{"version":1,"type":"doc","content":[
8638          {"type":"bulletList","content":[
8639            {"type":"listItem","content":[
8640              {"type":"paragraph","content":[
8641                {"type":"text","text":"intro"},
8642                {"type":"hardBreak"}
8643              ]},
8644              {"type":"panel","attrs":{"panelType":"info"},"content":[
8645                {"type":"paragraph","content":[
8646                  {"type":"text","text":"panel body"}
8647                ]}
8648              ]}
8649            ]}
8650          ]}
8651        ]}"#;
8652        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8653        let md = adf_to_markdown(&doc).unwrap();
8654        let round_tripped = markdown_to_adf(&md).unwrap();
8655
8656        let item = &round_tripped.content[0].content.as_ref().unwrap()[0];
8657        let item_content = item.content.as_ref().unwrap();
8658        assert!(
8659            item_content.iter().any(|n| n.node_type == "panel"),
8660            "panel should survive as block-level sibling, got: {item_content:?}"
8661        );
8662    }
8663
8664    #[test]
8665    fn inline_code_with_unicode_shortcode_pattern_round_trips() {
8666        // Issue #552: Inline code containing non-ASCII colon-delimited words
8667        // must round-trip without emoji corruption.
8668        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8669          {"type":"text","text":"See "},
8670          {"type":"text","text":"ZBC::配置::Production","marks":[{"type":"code"}]},
8671          {"type":"text","text":" for prod"}
8672        ]}]}"#;
8673        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8674
8675        let md = adf_to_markdown(&doc).unwrap();
8676        let round_tripped = markdown_to_adf(&md).unwrap();
8677        let content = round_tripped.content[0].content.as_ref().unwrap();
8678
8679        assert_eq!(content.len(), 3, "should have 3 nodes: {content:?}");
8680        assert_eq!(content[1].text.as_deref(), Some("ZBC::配置::Production"));
8681        assert!(content[1]
8682            .marks
8683            .as_ref()
8684            .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
8685    }
8686
8687    #[test]
8688    fn code_block_followed_by_shortcode_text_round_trips() {
8689        // Issue #552: A code block with colon-delimited content followed by a
8690        // paragraph containing emoji-like text should not confuse parsing.
8691        let adf_json = r#"{"version":1,"type":"doc","content":[
8692          {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8693            {"type":"text","text":"Foo::Bar::Baz"}
8694          ]},
8695          {"type":"paragraph","content":[
8696            {"type":"text","text":"Status::Active::Running"}
8697          ]}
8698        ]}"#;
8699        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8700
8701        let md = adf_to_markdown(&doc).unwrap();
8702        let round_tripped = markdown_to_adf(&md).unwrap();
8703
8704        assert_eq!(round_tripped.content.len(), 2);
8705        let cb = &round_tripped.content[0];
8706        assert_eq!(cb.node_type, "codeBlock");
8707        let cb_content = cb.content.as_ref().unwrap();
8708        assert_eq!(cb_content[0].text.as_deref(), Some("Foo::Bar::Baz"));
8709
8710        let para = &round_tripped.content[1];
8711        assert_eq!(para.node_type, "paragraph");
8712        let para_content = para.content.as_ref().unwrap();
8713        assert_eq!(para_content.len(), 1);
8714        assert_eq!(para_content[0].node_type, "text");
8715        assert_eq!(
8716            para_content[0].text.as_deref(),
8717            Some("Status::Active::Running")
8718        );
8719    }
8720
8721    #[test]
8722    fn adf_inline_card_to_markdown() {
8723        let doc = AdfDocument {
8724            version: 1,
8725            doc_type: "doc".to_string(),
8726            content: vec![AdfNode::paragraph(vec![AdfNode {
8727                node_type: "inlineCard".to_string(),
8728                attrs: Some(
8729                    serde_json::json!({"url": "https://org.atlassian.net/browse/ACCS-4382"}),
8730                ),
8731                content: None,
8732                text: None,
8733                marks: None,
8734                local_id: None,
8735                parameters: None,
8736            }])],
8737        };
8738        let md = adf_to_markdown(&doc).unwrap();
8739        assert!(md.contains(":card[https://org.atlassian.net/browse/ACCS-4382]"));
8740        assert!(!md.contains("<!-- unsupported inline"));
8741    }
8742
8743    #[test]
8744    fn inline_card_directive_round_trips() {
8745        // inlineCard → :card[url] → inlineCard
8746        let original = AdfDocument {
8747            version: 1,
8748            doc_type: "doc".to_string(),
8749            content: vec![AdfNode::paragraph(vec![AdfNode::inline_card(
8750                "https://org.atlassian.net/browse/ACCS-4382",
8751            )])],
8752        };
8753        let md = adf_to_markdown(&original).unwrap();
8754        assert!(md.contains(":card[https://org.atlassian.net/browse/ACCS-4382]"));
8755        let restored = markdown_to_adf(&md).unwrap();
8756        let node = &restored.content[0].content.as_ref().unwrap()[0];
8757        assert_eq!(node.node_type, "inlineCard");
8758        assert_eq!(
8759            node.attrs.as_ref().unwrap()["url"],
8760            "https://org.atlassian.net/browse/ACCS-4382"
8761        );
8762    }
8763
8764    #[test]
8765    fn inline_card_directive_parsed_from_jfm() {
8766        // :card[url] in JFM → inlineCard in ADF
8767        let doc = markdown_to_adf("See :card[https://example.com/issue/123] for details.").unwrap();
8768        let nodes = doc.content[0].content.as_ref().unwrap();
8769        assert_eq!(nodes[0].node_type, "text");
8770        assert_eq!(nodes[0].text.as_deref(), Some("See "));
8771        assert_eq!(nodes[1].node_type, "inlineCard");
8772        assert_eq!(
8773            nodes[1].attrs.as_ref().unwrap()["url"],
8774            "https://example.com/issue/123"
8775        );
8776        assert_eq!(nodes[2].node_type, "text");
8777        assert_eq!(nodes[2].text.as_deref(), Some(" for details."));
8778    }
8779
8780    #[test]
8781    fn self_link_becomes_link_mark_not_inline_card() {
8782        // Issue #378: [url](url) should produce a link mark, not inlineCard.
8783        // inlineCard is only for :card[url] directives and bare URLs.
8784        let doc = markdown_to_adf("[https://example.com](https://example.com)").unwrap();
8785        let node = &doc.content[0].content.as_ref().unwrap()[0];
8786        assert_eq!(node.node_type, "text");
8787        assert_eq!(node.text.as_deref(), Some("https://example.com"));
8788        let mark = &node.marks.as_ref().unwrap()[0];
8789        assert_eq!(mark.mark_type, "link");
8790        assert_eq!(mark.attrs.as_ref().unwrap()["href"], "https://example.com");
8791    }
8792
8793    #[test]
8794    fn url_link_text_with_trailing_slash_mismatch_becomes_link_mark() {
8795        // Issue #523: [url](url/) where text and href differ only by trailing
8796        // slash should produce a text node with link mark, not an inlineCard.
8797        let doc =
8798            markdown_to_adf("[https://octopz.example.com](https://octopz.example.com/)").unwrap();
8799        let node = &doc.content[0].content.as_ref().unwrap()[0];
8800        assert_eq!(node.node_type, "text");
8801        assert_eq!(node.text.as_deref(), Some("https://octopz.example.com"));
8802        let mark = &node.marks.as_ref().unwrap()[0];
8803        assert_eq!(mark.mark_type, "link");
8804        assert_eq!(
8805            mark.attrs.as_ref().unwrap()["href"],
8806            "https://octopz.example.com/"
8807        );
8808    }
8809
8810    #[test]
8811    fn named_link_does_not_become_inline_card() {
8812        // [#4668](url) — text differs from url, stays as a link mark
8813        let doc = markdown_to_adf("[#4668](https://github.com/org/repo/pull/4668)").unwrap();
8814        let node = &doc.content[0].content.as_ref().unwrap()[0];
8815        assert_eq!(node.node_type, "text");
8816        assert_eq!(node.text.as_deref(), Some("#4668"));
8817        let mark = &node.marks.as_ref().unwrap()[0];
8818        assert_eq!(mark.mark_type, "link");
8819    }
8820
8821    #[test]
8822    fn adf_inline_card_no_url_to_markdown() {
8823        let doc = AdfDocument {
8824            version: 1,
8825            doc_type: "doc".to_string(),
8826            content: vec![AdfNode::paragraph(vec![AdfNode {
8827                node_type: "inlineCard".to_string(),
8828                attrs: Some(serde_json::json!({})),
8829                content: None,
8830                text: None,
8831                marks: None,
8832                local_id: None,
8833                parameters: None,
8834            }])],
8835        };
8836        let md = adf_to_markdown(&doc).unwrap();
8837        // No url attr — renders nothing (not a comment)
8838        assert!(!md.contains("<!-- unsupported inline"));
8839    }
8840
8841    #[test]
8842    fn adf_code_block_no_language_to_markdown() {
8843        let doc = AdfDocument {
8844            version: 1,
8845            doc_type: "doc".to_string(),
8846            content: vec![AdfNode::code_block(None, "plain code")],
8847        };
8848        let md = adf_to_markdown(&doc).unwrap();
8849        assert!(md.contains("```\n"));
8850        assert!(md.contains("plain code"));
8851    }
8852
8853    #[test]
8854    fn adf_code_block_empty_language_to_markdown() {
8855        let doc = AdfDocument {
8856            version: 1,
8857            doc_type: "doc".to_string(),
8858            content: vec![AdfNode::code_block(Some(""), "plain code")],
8859        };
8860        let md = adf_to_markdown(&doc).unwrap();
8861        assert!(md.contains("```\"\"\n"));
8862        assert!(md.contains("plain code"));
8863    }
8864
8865    // ── Additional round-trip tests ────────────────────────────────────
8866
8867    #[test]
8868    fn round_trip_table() {
8869        let md = "| A | B |\n| --- | --- |\n| 1 | 2 |\n";
8870        let adf = markdown_to_adf(md).unwrap();
8871        let restored = adf_to_markdown(&adf).unwrap();
8872        assert!(restored.contains("| A | B |"));
8873        assert!(restored.contains("| 1 | 2 |"));
8874    }
8875
8876    #[test]
8877    fn round_trip_blockquote() {
8878        let md = "> This is quoted\n";
8879        let adf = markdown_to_adf(md).unwrap();
8880        let restored = adf_to_markdown(&adf).unwrap();
8881        assert!(restored.contains("> This is quoted"));
8882    }
8883
8884    #[test]
8885    fn round_trip_image() {
8886        let md = "![Logo](https://example.com/logo.png)\n";
8887        let adf = markdown_to_adf(md).unwrap();
8888        let restored = adf_to_markdown(&adf).unwrap();
8889        assert!(restored.contains("![Logo](https://example.com/logo.png)"));
8890    }
8891
8892    #[test]
8893    fn round_trip_ordered_list() {
8894        let md = "1. A\n2. B\n3. C\n";
8895        let adf = markdown_to_adf(md).unwrap();
8896        let restored = adf_to_markdown(&adf).unwrap();
8897        assert!(restored.contains("1. A"));
8898        assert!(restored.contains("2. B"));
8899        assert!(restored.contains("3. C"));
8900    }
8901
8902    #[test]
8903    fn round_trip_inline_marks() {
8904        let md = "Text with `code` and ~~strike~~ and [link](https://x.com).\n";
8905        let adf = markdown_to_adf(md).unwrap();
8906        let restored = adf_to_markdown(&adf).unwrap();
8907        assert!(restored.contains("`code`"));
8908        assert!(restored.contains("~~strike~~"));
8909        assert!(restored.contains("[link](https://x.com)"));
8910    }
8911
8912    // ── Container directive tests (Tier 2) ───────────────────────────
8913
8914    #[test]
8915    fn panel_info() {
8916        let md = ":::panel{type=info}\nThis is informational.\n:::";
8917        let doc = markdown_to_adf(md).unwrap();
8918        assert_eq!(doc.content[0].node_type, "panel");
8919        assert_eq!(doc.content[0].attrs.as_ref().unwrap()["panelType"], "info");
8920        let inner = doc.content[0].content.as_ref().unwrap();
8921        assert_eq!(inner[0].node_type, "paragraph");
8922    }
8923
8924    #[test]
8925    fn adf_panel_to_markdown() {
8926        let doc = AdfDocument {
8927            version: 1,
8928            doc_type: "doc".to_string(),
8929            content: vec![AdfNode::panel(
8930                "warning",
8931                vec![AdfNode::paragraph(vec![AdfNode::text("Be careful.")])],
8932            )],
8933        };
8934        let md = adf_to_markdown(&doc).unwrap();
8935        assert!(md.contains(":::panel{type=warning}"));
8936        assert!(md.contains("Be careful."));
8937        assert!(md.contains(":::"));
8938    }
8939
8940    #[test]
8941    fn round_trip_panel() {
8942        let md = ":::panel{type=info}\nThis is informational.\n:::\n";
8943        let doc = markdown_to_adf(md).unwrap();
8944        let result = adf_to_markdown(&doc).unwrap();
8945        assert!(result.contains(":::panel{type=info}"));
8946        assert!(result.contains("This is informational."));
8947    }
8948
8949    #[test]
8950    fn expand_with_title() {
8951        let md = ":::expand{title=\"Click me\"}\nHidden content.\n:::";
8952        let doc = markdown_to_adf(md).unwrap();
8953        assert_eq!(doc.content[0].node_type, "expand");
8954        assert_eq!(doc.content[0].attrs.as_ref().unwrap()["title"], "Click me");
8955    }
8956
8957    #[test]
8958    fn adf_expand_to_markdown() {
8959        let doc = AdfDocument {
8960            version: 1,
8961            doc_type: "doc".to_string(),
8962            content: vec![AdfNode::expand(
8963                Some("Details"),
8964                vec![AdfNode::paragraph(vec![AdfNode::text("Inner.")])],
8965            )],
8966        };
8967        let md = adf_to_markdown(&doc).unwrap();
8968        assert!(md.contains(":::expand{title=\"Details\"}"));
8969        assert!(md.contains("Inner."));
8970    }
8971
8972    #[test]
8973    fn round_trip_expand() {
8974        let md = ":::expand{title=\"Details\"}\nInner content.\n:::\n";
8975        let doc = markdown_to_adf(md).unwrap();
8976        let result = adf_to_markdown(&doc).unwrap();
8977        assert!(result.contains(":::expand{title=\"Details\"}"));
8978        assert!(result.contains("Inner content."));
8979    }
8980
8981    #[test]
8982    fn layout_two_columns() {
8983        let md =
8984            "::::layout\n:::column{width=50}\nLeft.\n:::\n:::column{width=50}\nRight.\n:::\n::::";
8985        let doc = markdown_to_adf(md).unwrap();
8986        assert_eq!(doc.content[0].node_type, "layoutSection");
8987        let columns = doc.content[0].content.as_ref().unwrap();
8988        assert_eq!(columns.len(), 2);
8989        assert_eq!(columns[0].node_type, "layoutColumn");
8990        assert_eq!(columns[1].node_type, "layoutColumn");
8991    }
8992
8993    #[test]
8994    fn adf_layout_to_markdown() {
8995        let doc = AdfDocument {
8996            version: 1,
8997            doc_type: "doc".to_string(),
8998            content: vec![AdfNode::layout_section(vec![
8999                AdfNode::layout_column(50, vec![AdfNode::paragraph(vec![AdfNode::text("Left.")])]),
9000                AdfNode::layout_column(50, vec![AdfNode::paragraph(vec![AdfNode::text("Right.")])]),
9001            ])],
9002        };
9003        let md = adf_to_markdown(&doc).unwrap();
9004        assert!(md.contains("::::layout"));
9005        assert!(md.contains(":::column{width=50}"));
9006        assert!(md.contains("Left."));
9007        assert!(md.contains("Right."));
9008    }
9009
9010    #[test]
9011    fn layout_column_localid_roundtrip() {
9012        let adf_json = r#"{
9013            "version": 1,
9014            "type": "doc",
9015            "content": [{
9016                "type": "layoutSection",
9017                "content": [
9018                    {
9019                        "type": "layoutColumn",
9020                        "attrs": {"width": 50.0, "localId": "aabb112233cc"},
9021                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Left"}]}]
9022                    },
9023                    {
9024                        "type": "layoutColumn",
9025                        "attrs": {"width": 50.0, "localId": "ddeeff445566"},
9026                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Right"}]}]
9027                    }
9028                ]
9029            }]
9030        }"#;
9031        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9032        let md = adf_to_markdown(&doc).unwrap();
9033        assert!(
9034            md.contains("localId=aabb112233cc"),
9035            "first column localId should appear in markdown: {md}"
9036        );
9037        assert!(
9038            md.contains("localId=ddeeff445566"),
9039            "second column localId should appear in markdown: {md}"
9040        );
9041        let rt = markdown_to_adf(&md).unwrap();
9042        let cols = rt.content[0].content.as_ref().unwrap();
9043        assert_eq!(
9044            cols[0].attrs.as_ref().unwrap()["localId"],
9045            "aabb112233cc",
9046            "first column localId should round-trip"
9047        );
9048        assert_eq!(
9049            cols[1].attrs.as_ref().unwrap()["localId"],
9050            "ddeeff445566",
9051            "second column localId should round-trip"
9052        );
9053    }
9054
9055    #[test]
9056    fn layout_column_without_localid() {
9057        let md =
9058            "::::layout\n:::column{width=50}\nLeft.\n:::\n:::column{width=50}\nRight.\n:::\n::::";
9059        let doc = markdown_to_adf(md).unwrap();
9060        let cols = doc.content[0].content.as_ref().unwrap();
9061        assert!(
9062            cols[0].attrs.as_ref().unwrap().get("localId").is_none(),
9063            "column without localId should not gain one"
9064        );
9065        let md2 = adf_to_markdown(&doc).unwrap();
9066        assert!(
9067            !md2.contains("localId"),
9068            "no localId should appear in output: {md2}"
9069        );
9070    }
9071
9072    #[test]
9073    fn layout_column_localid_stripped_when_option_set() {
9074        let adf_json = r#"{
9075            "version": 1,
9076            "type": "doc",
9077            "content": [{
9078                "type": "layoutSection",
9079                "content": [{
9080                    "type": "layoutColumn",
9081                    "attrs": {"width": 50.0, "localId": "aabb112233cc"},
9082                    "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Col"}]}]
9083                }]
9084            }]
9085        }"#;
9086        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9087        let opts = RenderOptions {
9088            strip_local_ids: true,
9089            ..Default::default()
9090        };
9091        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
9092        assert!(!md.contains("localId"), "localId should be stripped: {md}");
9093    }
9094
9095    #[test]
9096    fn layout_column_localid_flush_previous() {
9097        // Columns open without explicit `:::` close → flush-previous path
9098        let md = "::::layout\n:::column{width=50 localId=aabb112233cc}\nLeft.\n:::column{width=50 localId=ddeeff445566}\nRight.\n:::\n::::";
9099        let doc = markdown_to_adf(md).unwrap();
9100        let cols = doc.content[0].content.as_ref().unwrap();
9101        assert_eq!(
9102            cols[0].attrs.as_ref().unwrap()["localId"],
9103            "aabb112233cc",
9104            "flush-previous column should preserve localId"
9105        );
9106        assert_eq!(
9107            cols[1].attrs.as_ref().unwrap()["localId"],
9108            "ddeeff445566",
9109            "second column localId should be preserved"
9110        );
9111    }
9112
9113    #[test]
9114    fn layout_column_localid_flush_last() {
9115        // Layout with no closing fence → column never explicitly closed → flush-last path
9116        let md = "::::layout\n:::column{width=50 localId=aabb112233cc}\nOnly column.";
9117        let doc = markdown_to_adf(md).unwrap();
9118        let cols = doc.content[0].content.as_ref().unwrap();
9119        assert_eq!(
9120            cols[0].attrs.as_ref().unwrap()["localId"],
9121            "aabb112233cc",
9122            "flush-last column should preserve localId"
9123        );
9124    }
9125
9126    /// Issue #555: `layoutColumn` fractional `width` must round-trip byte-for-byte.
9127    #[test]
9128    fn issue_555_layout_column_fractional_width_roundtrip() {
9129        let adf_json = r#"{
9130            "version": 1,
9131            "type": "doc",
9132            "content": [{
9133                "type": "layoutSection",
9134                "content": [
9135                    {
9136                        "type": "layoutColumn",
9137                        "attrs": {"width": 66.66},
9138                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Wide"}]}]
9139                    },
9140                    {
9141                        "type": "layoutColumn",
9142                        "attrs": {"width": 33.34},
9143                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Narrow"}]}]
9144                    }
9145                ]
9146            }]
9147        }"#;
9148        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9149        let md = adf_to_markdown(&doc).unwrap();
9150        assert!(md.contains("width=66.66"), "fractional width in md: {md}");
9151        assert!(md.contains("width=33.34"), "fractional width in md: {md}");
9152        let rt = markdown_to_adf(&md).unwrap();
9153        let cols = rt.content[0].content.as_ref().unwrap();
9154        assert_eq!(cols[0].attrs.as_ref().unwrap()["width"], 66.66);
9155        assert_eq!(cols[1].attrs.as_ref().unwrap()["width"], 33.34);
9156    }
9157
9158    /// Issue #555: `layoutColumn` 5/6 widths (`83.33`) round-trip without precision loss.
9159    #[test]
9160    fn issue_555_layout_column_five_sixths_width_roundtrip() {
9161        let adf_json = r#"{
9162            "version": 1,
9163            "type": "doc",
9164            "content": [{
9165                "type": "layoutSection",
9166                "content": [
9167                    {
9168                        "type": "layoutColumn",
9169                        "attrs": {"width": 83.33},
9170                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Wide"}]}]
9171                    },
9172                    {
9173                        "type": "layoutColumn",
9174                        "attrs": {"width": 16.67},
9175                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Narrow"}]}]
9176                    }
9177                ]
9178            }]
9179        }"#;
9180        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9181        let md = adf_to_markdown(&doc).unwrap();
9182        let rt = markdown_to_adf(&md).unwrap();
9183        let cols = rt.content[0].content.as_ref().unwrap();
9184        assert_eq!(cols[0].attrs.as_ref().unwrap()["width"], 83.33);
9185        assert_eq!(cols[1].attrs.as_ref().unwrap()["width"], 16.67);
9186    }
9187
9188    /// Issue #555: `layoutColumn` integer widths must NOT be coerced to floats on round-trip.
9189    #[test]
9190    fn issue_555_layout_column_integer_width_preserved() {
9191        let adf_json = r#"{
9192            "version": 1,
9193            "type": "doc",
9194            "content": [{
9195                "type": "layoutSection",
9196                "content": [
9197                    {
9198                        "type": "layoutColumn",
9199                        "attrs": {"width": 50},
9200                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "A"}]}]
9201                    },
9202                    {
9203                        "type": "layoutColumn",
9204                        "attrs": {"width": 50},
9205                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "B"}]}]
9206                    }
9207                ]
9208            }]
9209        }"#;
9210        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9211        let md = adf_to_markdown(&doc).unwrap();
9212        assert!(
9213            md.contains("width=50") && !md.contains("width=50."),
9214            "integer width should render without decimal: {md}"
9215        );
9216        let rt = markdown_to_adf(&md).unwrap();
9217        let cols = rt.content[0].content.as_ref().unwrap();
9218        let w0 = &cols[0].attrs.as_ref().unwrap()["width"];
9219        assert!(
9220            w0.is_i64() || w0.is_u64(),
9221            "width should remain a JSON integer, got: {w0}"
9222        );
9223        assert_eq!(w0.as_i64(), Some(50));
9224    }
9225
9226    /// Issue #555: `mediaSingle` integer `width` must NOT be coerced to a float on round-trip.
9227    #[test]
9228    fn issue_555_media_single_integer_width_preserved() {
9229        let adf_json = r#"{
9230            "version": 1,
9231            "type": "doc",
9232            "content": [{
9233                "type": "mediaSingle",
9234                "attrs": {"layout": "center", "width": 800},
9235                "content": [
9236                    {"type": "media", "attrs": {"type": "external", "url": "https://example.com/image.png"}}
9237                ]
9238            }]
9239        }"#;
9240        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9241        let md = adf_to_markdown(&doc).unwrap();
9242        assert!(
9243            md.contains("width=800") && !md.contains("width=800."),
9244            "integer width should render without decimal: {md}"
9245        );
9246        let rt = markdown_to_adf(&md).unwrap();
9247        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9248        let w = &ms_attrs["width"];
9249        assert!(
9250            w.is_i64() || w.is_u64(),
9251            "mediaSingle width should remain JSON integer, got: {w}"
9252        );
9253        assert_eq!(w.as_i64(), Some(800));
9254    }
9255
9256    /// Issue #555 (follow-up): fractional `mediaSingle` width (e.g. `66.5`, a
9257    /// percentage-based size common in Jira layouts) must survive `from-adf`
9258    /// instead of being silently dropped.
9259    #[test]
9260    fn issue_555_media_single_fractional_width_preserved() {
9261        let adf_json = r#"{
9262            "version": 1,
9263            "type": "doc",
9264            "content": [{
9265                "type": "mediaSingle",
9266                "attrs": {"layout": "center", "width": 66.5},
9267                "content": [
9268                    {"type": "media", "attrs": {"type": "external", "url": "https://example.com/diagram.png"}}
9269                ]
9270            }]
9271        }"#;
9272        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9273        let md = adf_to_markdown(&doc).unwrap();
9274        assert!(
9275            md.contains("width=66.5"),
9276            "fractional width must appear in JFM: {md}"
9277        );
9278        let rt = markdown_to_adf(&md).unwrap();
9279        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9280        assert_eq!(ms_attrs["width"], 66.5);
9281    }
9282
9283    /// Issue #555: `mediaSingle` float `width` must not be dropped during ADF→JFM→ADF.
9284    #[test]
9285    fn issue_555_media_single_float_width_preserved() {
9286        let adf_json = r#"{
9287            "version": 1,
9288            "type": "doc",
9289            "content": [{
9290                "type": "mediaSingle",
9291                "attrs": {"layout": "center", "width": 800.0},
9292                "content": [
9293                    {"type": "media", "attrs": {"type": "external", "url": "https://example.com/image.png"}}
9294                ]
9295            }]
9296        }"#;
9297        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9298        let md = adf_to_markdown(&doc).unwrap();
9299        assert!(
9300            md.contains("width=800.0"),
9301            "float width should render with decimal: {md}"
9302        );
9303        let rt = markdown_to_adf(&md).unwrap();
9304        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9305        let w = &ms_attrs["width"];
9306        assert!(
9307            w.is_f64(),
9308            "mediaSingle float width should stay a JSON float, got: {w}"
9309        );
9310        assert_eq!(w.as_f64(), Some(800.0));
9311    }
9312
9313    /// Issue #555: `mediaSingle` with `layout=wide` and integer width must round-trip.
9314    #[test]
9315    fn issue_555_media_single_wide_layout_integer_width_roundtrip() {
9316        let adf_json = r#"{
9317            "version": 1,
9318            "type": "doc",
9319            "content": [{
9320                "type": "mediaSingle",
9321                "attrs": {"layout": "wide", "width": 1420},
9322                "content": [
9323                    {"type": "media", "attrs": {"type": "external", "url": "https://ex.com/x.png"}}
9324                ]
9325            }]
9326        }"#;
9327        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9328        let md = adf_to_markdown(&doc).unwrap();
9329        let rt = markdown_to_adf(&md).unwrap();
9330        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9331        assert_eq!(ms_attrs["layout"], "wide");
9332        let w = &ms_attrs["width"];
9333        assert!(
9334            w.is_i64() || w.is_u64(),
9335            "mediaSingle width should remain JSON integer, got: {w}"
9336        );
9337        assert_eq!(w.as_i64(), Some(1420));
9338    }
9339
9340    /// Issue #555: Confluence file-attachment `mediaSingle` with integer `mediaWidth`
9341    /// must round-trip without float coercion.
9342    #[test]
9343    fn issue_555_file_media_single_integer_width_preserved() {
9344        let adf_json = r#"{
9345            "version": 1,
9346            "type": "doc",
9347            "content": [{
9348                "type": "mediaSingle",
9349                "attrs": {"layout": "wide", "width": 1420},
9350                "content": [
9351                    {"type": "media", "attrs": {"id": "abc-123", "type": "file", "collection": "col-1", "width": 1200, "height": 800}}
9352                ]
9353            }]
9354        }"#;
9355        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9356        let md = adf_to_markdown(&doc).unwrap();
9357        let rt = markdown_to_adf(&md).unwrap();
9358        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9359        let ms_w = &ms_attrs["width"];
9360        assert!(ms_w.is_i64() || ms_w.is_u64(), "ms width: {ms_w}");
9361        assert_eq!(ms_w.as_i64(), Some(1420));
9362        let media = &rt.content[0].content.as_ref().unwrap()[0];
9363        let media_attrs = media.attrs.as_ref().unwrap();
9364        let mw = &media_attrs["width"];
9365        assert!(mw.is_i64() || mw.is_u64(), "media width: {mw}");
9366        assert_eq!(mw.as_i64(), Some(1200));
9367        let mh = &media_attrs["height"];
9368        assert!(mh.is_i64() || mh.is_u64(), "media height: {mh}");
9369        assert_eq!(mh.as_i64(), Some(800));
9370    }
9371
9372    /// Issue #555: `fmt_numeric_attr` preserves the original integer/float JSON type.
9373    #[test]
9374    fn issue_555_fmt_numeric_attr_preserves_type() {
9375        assert_eq!(
9376            fmt_numeric_attr(&serde_json::json!(50)).as_deref(),
9377            Some("50")
9378        );
9379        assert_eq!(
9380            fmt_numeric_attr(&serde_json::json!(50.0)).as_deref(),
9381            Some("50.0")
9382        );
9383        assert_eq!(
9384            fmt_numeric_attr(&serde_json::json!(66.66)).as_deref(),
9385            Some("66.66")
9386        );
9387        assert_eq!(
9388            fmt_numeric_attr(&serde_json::json!(-5)).as_deref(),
9389            Some("-5")
9390        );
9391        assert_eq!(fmt_numeric_attr(&serde_json::json!("not a number")), None);
9392        // u64 values above i64::MAX exercise the u64-only branch.
9393        let big = serde_json::Value::Number(serde_json::Number::from(u64::MAX));
9394        assert_eq!(
9395            fmt_numeric_attr(&big).as_deref(),
9396            Some("18446744073709551615")
9397        );
9398        // Null is not a number.
9399        assert_eq!(fmt_numeric_attr(&serde_json::Value::Null), None);
9400    }
9401
9402    /// Issue #555: `parse_numeric_attr` distinguishes integer vs float strings.
9403    #[test]
9404    fn issue_555_parse_numeric_attr_detects_type() {
9405        let v = parse_numeric_attr("800").unwrap();
9406        assert!(v.is_i64() || v.is_u64(), "'800' should parse as integer");
9407        assert_eq!(v.as_i64(), Some(800));
9408
9409        let v = parse_numeric_attr("800.0").unwrap();
9410        assert!(v.is_f64(), "'800.0' should parse as float");
9411        assert_eq!(v.as_f64(), Some(800.0));
9412
9413        let v = parse_numeric_attr("66.66").unwrap();
9414        assert!(v.is_f64());
9415        assert_eq!(v.as_f64(), Some(66.66));
9416
9417        // Scientific notation is treated as float (matches JSON semantics).
9418        let v = parse_numeric_attr("1e2").unwrap();
9419        assert!(v.is_f64());
9420        assert_eq!(v.as_f64(), Some(100.0));
9421        let v = parse_numeric_attr("1E2").unwrap();
9422        assert!(v.is_f64());
9423        assert_eq!(v.as_f64(), Some(100.0));
9424
9425        // Negative integer.
9426        let v = parse_numeric_attr("-42").unwrap();
9427        assert!(v.is_i64());
9428        assert_eq!(v.as_i64(), Some(-42));
9429
9430        assert!(parse_numeric_attr("not-a-number").is_none());
9431        assert!(parse_numeric_attr("").is_none());
9432        assert!(parse_numeric_attr("1.2.3").is_none());
9433    }
9434
9435    /// Issue #555: fractional `mediaSingle` width with non-default `layout=wide`
9436    /// must preserve both the layout and the fractional width through round-trip.
9437    #[test]
9438    fn issue_555_media_single_wide_layout_fractional_width_roundtrip() {
9439        let adf_json = r#"{
9440            "version": 1,
9441            "type": "doc",
9442            "content": [{
9443                "type": "mediaSingle",
9444                "attrs": {"layout": "wide", "width": 83.33},
9445                "content": [
9446                    {"type": "media", "attrs": {"type": "external", "url": "https://ex.com/x.png"}}
9447                ]
9448            }]
9449        }"#;
9450        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9451        let md = adf_to_markdown(&doc).unwrap();
9452        assert!(md.contains("layout=wide"), "layout must appear in md: {md}");
9453        assert!(md.contains("width=83.33"), "width must appear in md: {md}");
9454        let rt = markdown_to_adf(&md).unwrap();
9455        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9456        assert_eq!(ms_attrs["layout"], "wide");
9457        assert_eq!(ms_attrs["width"], 83.33);
9458    }
9459
9460    /// Issue #555: fractional `mediaWidth` on a Confluence file-attachment
9461    /// `mediaSingle` must round-trip (exercises the file-branch `mediaWidth`
9462    /// render path, which previously used `as_u64` and silently dropped floats).
9463    #[test]
9464    fn issue_555_file_media_single_fractional_media_width_preserved() {
9465        let adf_json = r#"{
9466            "version": 1,
9467            "type": "doc",
9468            "content": [{
9469                "type": "mediaSingle",
9470                "attrs": {"layout": "wide", "width": 66.5},
9471                "content": [
9472                    {"type": "media", "attrs": {"id": "abc", "type": "file", "collection": "c"}}
9473                ]
9474            }]
9475        }"#;
9476        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9477        let md = adf_to_markdown(&doc).unwrap();
9478        assert!(md.contains("mediaWidth=66.5"), "mediaWidth in md: {md}");
9479        let rt = markdown_to_adf(&md).unwrap();
9480        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9481        assert_eq!(ms_attrs["width"], 66.5);
9482    }
9483
9484    /// Issue #555: fractional inner `media` width/height on a file attachment
9485    /// must round-trip (exercises the file-branch inner `width`/`height` render
9486    /// path, which previously used `as_u64` and silently dropped floats).
9487    #[test]
9488    fn issue_555_file_media_fractional_inner_dimensions_preserved() {
9489        let adf_json = r#"{
9490            "version": 1,
9491            "type": "doc",
9492            "content": [{
9493                "type": "mediaSingle",
9494                "attrs": {"layout": "center"},
9495                "content": [
9496                    {"type": "media", "attrs": {"id": "abc", "type": "file", "collection": "c", "width": 1200.5, "height": 800.25}}
9497                ]
9498            }]
9499        }"#;
9500        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9501        let md = adf_to_markdown(&doc).unwrap();
9502        assert!(md.contains("width=1200.5"), "width in md: {md}");
9503        assert!(md.contains("height=800.25"), "height in md: {md}");
9504        let rt = markdown_to_adf(&md).unwrap();
9505        let media = &rt.content[0].content.as_ref().unwrap()[0];
9506        let attrs = media.attrs.as_ref().unwrap();
9507        assert_eq!(attrs["width"], 1200.5);
9508        assert_eq!(attrs["height"], 800.25);
9509    }
9510
9511    #[test]
9512    fn decisions_list() {
9513        let md = ":::decisions\n- <> Use PostgreSQL\n- <> REST API\n:::";
9514        let doc = markdown_to_adf(md).unwrap();
9515        assert_eq!(doc.content[0].node_type, "decisionList");
9516        let items = doc.content[0].content.as_ref().unwrap();
9517        assert_eq!(items.len(), 2);
9518        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DECIDED");
9519    }
9520
9521    // decisionItem is inline-only per ADF schema — its content must be
9522    // text/inline nodes, not a paragraph wrapper (issue #753).
9523    #[test]
9524    fn decision_item_content_is_inline_not_paragraph() {
9525        let md = ":::decisions\n- <> Use Rust\n:::";
9526        let doc = markdown_to_adf(md).unwrap();
9527        let items = doc.content[0].content.as_ref().unwrap();
9528        let first_child = &items[0].content.as_ref().unwrap()[0];
9529        assert_eq!(
9530            first_child.node_type, "text",
9531            "decisionItem must contain inline nodes directly, not a paragraph wrapper"
9532        );
9533        assert_eq!(first_child.text.as_deref(), Some("Use Rust"));
9534    }
9535
9536    #[test]
9537    fn adf_decisions_to_markdown() {
9538        let doc = AdfDocument {
9539            version: 1,
9540            doc_type: "doc".to_string(),
9541            content: vec![AdfNode::decision_list(vec![AdfNode::decision_item(
9542                "DECIDED",
9543                vec![AdfNode::paragraph(vec![AdfNode::text("Use PostgreSQL")])],
9544            )])],
9545        };
9546        let md = adf_to_markdown(&doc).unwrap();
9547        assert!(md.contains(":::decisions"));
9548        assert!(md.contains("- <> Use PostgreSQL"));
9549    }
9550
9551    #[test]
9552    fn bodied_extension_container() {
9553        let md = ":::extension{type=com.forge key=my-macro}\nContent.\n:::";
9554        let doc = markdown_to_adf(md).unwrap();
9555        assert_eq!(doc.content[0].node_type, "bodiedExtension");
9556        assert_eq!(
9557            doc.content[0].attrs.as_ref().unwrap()["extensionType"],
9558            "com.forge"
9559        );
9560    }
9561
9562    #[test]
9563    fn adf_bodied_extension_to_markdown() {
9564        let doc = AdfDocument {
9565            version: 1,
9566            doc_type: "doc".to_string(),
9567            content: vec![AdfNode::bodied_extension(
9568                "com.forge",
9569                "my-macro",
9570                vec![AdfNode::paragraph(vec![AdfNode::text("Content.")])],
9571            )],
9572        };
9573        let md = adf_to_markdown(&doc).unwrap();
9574        assert!(md.contains(":::extension{type=com.forge key=my-macro}"));
9575        assert!(md.contains("Content."));
9576    }
9577
9578    // ── Leaf block directive tests (Tier 3) ──────────────────────────
9579
9580    #[test]
9581    fn leaf_block_card() {
9582        let doc = markdown_to_adf("::card[https://example.com/browse/PROJ-123]").unwrap();
9583        assert_eq!(doc.content[0].node_type, "blockCard");
9584        assert_eq!(
9585            doc.content[0].attrs.as_ref().unwrap()["url"],
9586            "https://example.com/browse/PROJ-123"
9587        );
9588    }
9589
9590    #[test]
9591    fn adf_block_card_to_markdown() {
9592        let doc = AdfDocument {
9593            version: 1,
9594            doc_type: "doc".to_string(),
9595            content: vec![AdfNode::block_card("https://example.com/browse/PROJ-123")],
9596        };
9597        let md = adf_to_markdown(&doc).unwrap();
9598        assert!(md.contains("::card[https://example.com/browse/PROJ-123]"));
9599    }
9600
9601    #[test]
9602    fn round_trip_block_card() {
9603        let md = "::card[https://example.com/browse/PROJ-123]\n";
9604        let doc = markdown_to_adf(md).unwrap();
9605        let result = adf_to_markdown(&doc).unwrap();
9606        assert!(result.contains("::card[https://example.com/browse/PROJ-123]"));
9607    }
9608
9609    #[test]
9610    fn leaf_embed_card() {
9611        let doc =
9612            markdown_to_adf("::embed[https://figma.com/file/abc]{layout=wide width=80}").unwrap();
9613        assert_eq!(doc.content[0].node_type, "embedCard");
9614        let attrs = doc.content[0].attrs.as_ref().unwrap();
9615        assert_eq!(attrs["url"], "https://figma.com/file/abc");
9616        assert_eq!(attrs["layout"], "wide");
9617        assert_eq!(attrs["width"], 80.0);
9618    }
9619
9620    #[test]
9621    fn leaf_embed_card_with_original_height() {
9622        let doc = markdown_to_adf(
9623            "::embed[https://example.com]{layout=center originalHeight=732 width=100}",
9624        )
9625        .unwrap();
9626        assert_eq!(doc.content[0].node_type, "embedCard");
9627        let attrs = doc.content[0].attrs.as_ref().unwrap();
9628        assert_eq!(attrs["url"], "https://example.com");
9629        assert_eq!(attrs["layout"], "center");
9630        assert_eq!(attrs["originalHeight"], 732.0);
9631        assert_eq!(attrs["width"], 100.0);
9632    }
9633
9634    #[test]
9635    fn adf_embed_card_to_markdown() {
9636        let doc = AdfDocument {
9637            version: 1,
9638            doc_type: "doc".to_string(),
9639            content: vec![AdfNode::embed_card(
9640                "https://figma.com/file/abc",
9641                Some("wide"),
9642                None,
9643                Some(80.0),
9644            )],
9645        };
9646        let md = adf_to_markdown(&doc).unwrap();
9647        assert!(md.contains("::embed[https://figma.com/file/abc]{layout=wide width=80}"));
9648    }
9649
9650    #[test]
9651    fn adf_embed_card_original_height_to_markdown() {
9652        let doc = AdfDocument {
9653            version: 1,
9654            doc_type: "doc".to_string(),
9655            content: vec![AdfNode::embed_card(
9656                "https://example.com",
9657                Some("center"),
9658                Some(732.0),
9659                Some(100.0),
9660            )],
9661        };
9662        let md = adf_to_markdown(&doc).unwrap();
9663        assert!(
9664            md.contains("::embed[https://example.com]{layout=center originalHeight=732 width=100}"),
9665            "expected originalHeight and width in md: {md}"
9666        );
9667    }
9668
9669    #[test]
9670    fn embed_card_roundtrip_with_all_attrs() {
9671        let adf_json = r#"{"version":1,"type":"doc","content":[{
9672            "type":"embedCard",
9673            "attrs":{"layout":"center","originalHeight":732.0,"url":"https://example.com","width":100.0}
9674        }]}"#;
9675        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9676        let md = adf_to_markdown(&doc).unwrap();
9677        assert!(
9678            md.contains("originalHeight=732"),
9679            "originalHeight missing from md: {md}"
9680        );
9681        assert!(md.contains("width=100"), "width missing from md: {md}");
9682        let rt = markdown_to_adf(&md).unwrap();
9683        let attrs = rt.content[0].attrs.as_ref().unwrap();
9684        assert_eq!(attrs["originalHeight"], 732.0);
9685        assert_eq!(attrs["width"], 100.0);
9686        assert_eq!(attrs["layout"], "center");
9687        assert_eq!(attrs["url"], "https://example.com");
9688    }
9689
9690    #[test]
9691    fn embed_card_fractional_dimensions() {
9692        let doc = AdfDocument {
9693            version: 1,
9694            doc_type: "doc".to_string(),
9695            content: vec![AdfNode::embed_card(
9696                "https://example.com",
9697                Some("center"),
9698                Some(732.5),
9699                Some(99.9),
9700            )],
9701        };
9702        let md = adf_to_markdown(&doc).unwrap();
9703        assert!(
9704            md.contains("originalHeight=732.5"),
9705            "fractional originalHeight missing: {md}"
9706        );
9707        assert!(md.contains("width=99.9"), "fractional width missing: {md}");
9708        let rt = markdown_to_adf(&md).unwrap();
9709        let attrs = rt.content[0].attrs.as_ref().unwrap();
9710        assert_eq!(attrs["originalHeight"], 732.5);
9711        assert_eq!(attrs["width"], 99.9);
9712    }
9713
9714    #[test]
9715    fn embed_card_integer_width_in_json() {
9716        // JSON integer (not float) should also be extracted via as_f64()
9717        let adf_json = r#"{"version":1,"type":"doc","content":[{
9718            "type":"embedCard",
9719            "attrs":{"url":"https://example.com","width":100}
9720        }]}"#;
9721        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9722        let md = adf_to_markdown(&doc).unwrap();
9723        assert!(
9724            md.contains("width=100"),
9725            "integer width missing from md: {md}"
9726        );
9727        let rt = markdown_to_adf(&md).unwrap();
9728        assert_eq!(rt.content[0].attrs.as_ref().unwrap()["width"], 100.0);
9729    }
9730
9731    #[test]
9732    fn embed_card_only_original_height() {
9733        // originalHeight without width
9734        let adf_json = r#"{"version":1,"type":"doc","content":[{
9735            "type":"embedCard",
9736            "attrs":{"url":"https://example.com","originalHeight":500.0}
9737        }]}"#;
9738        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9739        let md = adf_to_markdown(&doc).unwrap();
9740        assert!(
9741            md.contains("originalHeight=500"),
9742            "originalHeight missing: {md}"
9743        );
9744        assert!(!md.contains("width="), "width should not appear: {md}");
9745        let rt = markdown_to_adf(&md).unwrap();
9746        let attrs = rt.content[0].attrs.as_ref().unwrap();
9747        assert_eq!(attrs["originalHeight"], 500.0);
9748        assert!(attrs.get("width").is_none());
9749    }
9750
9751    #[test]
9752    fn leaf_void_extension() {
9753        let doc = markdown_to_adf("::extension{type=com.atlassian.macro key=jira-chart}").unwrap();
9754        assert_eq!(doc.content[0].node_type, "extension");
9755        assert_eq!(
9756            doc.content[0].attrs.as_ref().unwrap()["extensionType"],
9757            "com.atlassian.macro"
9758        );
9759        assert_eq!(
9760            doc.content[0].attrs.as_ref().unwrap()["extensionKey"],
9761            "jira-chart"
9762        );
9763    }
9764
9765    #[test]
9766    fn adf_void_extension_to_markdown() {
9767        let doc = AdfDocument {
9768            version: 1,
9769            doc_type: "doc".to_string(),
9770            content: vec![AdfNode::extension(
9771                "com.atlassian.macro",
9772                "jira-chart",
9773                None,
9774            )],
9775        };
9776        let md = adf_to_markdown(&doc).unwrap();
9777        assert!(md.contains("::extension{type=com.atlassian.macro key=jira-chart}"));
9778    }
9779
9780    // ── Bare URL autolink tests ──────────────────────────────────────
9781
9782    #[test]
9783    fn bare_url_autolink() {
9784        let doc = markdown_to_adf("Visit https://example.com today").unwrap();
9785        let content = doc.content[0].content.as_ref().unwrap();
9786        assert_eq!(content[0].text.as_deref(), Some("Visit "));
9787        assert_eq!(content[1].node_type, "inlineCard");
9788        assert_eq!(
9789            content[1].attrs.as_ref().unwrap()["url"],
9790            "https://example.com"
9791        );
9792        assert_eq!(content[2].text.as_deref(), Some(" today"));
9793    }
9794
9795    #[test]
9796    fn bare_url_strips_trailing_punctuation() {
9797        let doc = markdown_to_adf("See https://example.com.").unwrap();
9798        let content = doc.content[0].content.as_ref().unwrap();
9799        assert_eq!(
9800            content[1].attrs.as_ref().unwrap()["url"],
9801            "https://example.com"
9802        );
9803    }
9804
9805    #[test]
9806    fn bare_url_round_trip() {
9807        let doc = markdown_to_adf("Visit https://example.com/path today").unwrap();
9808        let md = adf_to_markdown(&doc).unwrap();
9809        assert!(md.contains(":card[https://example.com/path]"));
9810    }
9811
9812    // ── Issue #475: plain-text URL must not become inlineCard ─────────
9813
9814    #[test]
9815    fn plain_text_url_round_trips_as_text() {
9816        // A text node whose content is a bare URL (no link mark) must
9817        // survive ADF→JFM→ADF as a text node, not an inlineCard.
9818        let adf_json = r#"{
9819            "version": 1,
9820            "type": "doc",
9821            "content": [{
9822                "type": "paragraph",
9823                "content": [
9824                    {"type": "text", "text": "https://example.com/some/path/to/resource"}
9825                ]
9826            }]
9827        }"#;
9828        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9829        let jfm = adf_to_markdown(&adf).unwrap();
9830        let roundtripped = markdown_to_adf(&jfm).unwrap();
9831        let content = roundtripped.content[0].content.as_ref().unwrap();
9832        assert_eq!(content.len(), 1, "should be a single node");
9833        assert_eq!(content[0].node_type, "text");
9834        assert_eq!(
9835            content[0].text.as_deref(),
9836            Some("https://example.com/some/path/to/resource")
9837        );
9838    }
9839
9840    #[test]
9841    fn url_text_with_link_mark_round_trips_as_text_node() {
9842        // Issue #523: A text node whose content is a URL with a link mark
9843        // (href differs by trailing slash) must round-trip as text+link,
9844        // not become an inlineCard.
9845        let adf_json = r#"{
9846            "version": 1,
9847            "type": "doc",
9848            "content": [{
9849                "type": "paragraph",
9850                "content": [{
9851                    "type": "text",
9852                    "text": "https://octopz.example.com",
9853                    "marks": [{"type": "link", "attrs": {"href": "https://octopz.example.com/"}}]
9854                }]
9855            }]
9856        }"#;
9857        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9858        let jfm = adf_to_markdown(&adf).unwrap();
9859        let roundtripped = markdown_to_adf(&jfm).unwrap();
9860        let content = roundtripped.content[0].content.as_ref().unwrap();
9861        assert_eq!(content.len(), 1, "should be a single node");
9862        assert_eq!(content[0].node_type, "text", "must be text, not inlineCard");
9863        assert_eq!(
9864            content[0].text.as_deref(),
9865            Some("https://octopz.example.com")
9866        );
9867        let mark = &content[0].marks.as_ref().unwrap()[0];
9868        assert_eq!(mark.mark_type, "link");
9869        assert_eq!(
9870            mark.attrs.as_ref().unwrap()["href"],
9871            "https://octopz.example.com/"
9872        );
9873    }
9874
9875    #[test]
9876    fn url_text_with_exact_link_mark_round_trips() {
9877        // Variant: text and href are identical (no trailing slash difference).
9878        let adf_json = r#"{
9879            "version": 1,
9880            "type": "doc",
9881            "content": [{
9882                "type": "paragraph",
9883                "content": [{
9884                    "type": "text",
9885                    "text": "https://example.com/path",
9886                    "marks": [{"type": "link", "attrs": {"href": "https://example.com/path"}}]
9887                }]
9888            }]
9889        }"#;
9890        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9891        let jfm = adf_to_markdown(&adf).unwrap();
9892        let roundtripped = markdown_to_adf(&jfm).unwrap();
9893        let content = roundtripped.content[0].content.as_ref().unwrap();
9894        assert_eq!(content.len(), 1, "should be a single node");
9895        assert_eq!(content[0].node_type, "text");
9896        assert_eq!(content[0].text.as_deref(), Some("https://example.com/path"));
9897        let mark = &content[0].marks.as_ref().unwrap()[0];
9898        assert_eq!(mark.mark_type, "link");
9899    }
9900
9901    #[test]
9902    fn plain_text_url_amid_text_round_trips() {
9903        // URL embedded in surrounding text, without link mark.
9904        let adf_json = r#"{
9905            "version": 1,
9906            "type": "doc",
9907            "content": [{
9908                "type": "paragraph",
9909                "content": [
9910                    {"type": "text", "text": "see https://example.com for info"}
9911                ]
9912            }]
9913        }"#;
9914        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9915        let jfm = adf_to_markdown(&adf).unwrap();
9916        let roundtripped = markdown_to_adf(&jfm).unwrap();
9917        let content = roundtripped.content[0].content.as_ref().unwrap();
9918        assert_eq!(content.len(), 1);
9919        assert_eq!(content[0].node_type, "text");
9920        assert_eq!(
9921            content[0].text.as_deref(),
9922            Some("see https://example.com for info")
9923        );
9924    }
9925
9926    #[test]
9927    fn plain_text_multiple_urls_round_trips() {
9928        let adf_json = r#"{
9929            "version": 1,
9930            "type": "doc",
9931            "content": [{
9932                "type": "paragraph",
9933                "content": [
9934                    {"type": "text", "text": "http://a.com and https://b.com"}
9935                ]
9936            }]
9937        }"#;
9938        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9939        let jfm = adf_to_markdown(&adf).unwrap();
9940        let roundtripped = markdown_to_adf(&jfm).unwrap();
9941        let content = roundtripped.content[0].content.as_ref().unwrap();
9942        assert_eq!(content.len(), 1);
9943        assert_eq!(content[0].node_type, "text");
9944        assert_eq!(
9945            content[0].text.as_deref(),
9946            Some("http://a.com and https://b.com")
9947        );
9948    }
9949
9950    #[test]
9951    fn plain_text_http_prefix_no_url_unchanged() {
9952        // "http" without "://" should not be escaped or altered.
9953        let adf_json = r#"{
9954            "version": 1,
9955            "type": "doc",
9956            "content": [{
9957                "type": "paragraph",
9958                "content": [
9959                    {"type": "text", "text": "the http header is important"}
9960                ]
9961            }]
9962        }"#;
9963        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9964        let jfm = adf_to_markdown(&adf).unwrap();
9965        let roundtripped = markdown_to_adf(&jfm).unwrap();
9966        let content = roundtripped.content[0].content.as_ref().unwrap();
9967        assert_eq!(
9968            content[0].text.as_deref(),
9969            Some("the http header is important")
9970        );
9971    }
9972
9973    #[test]
9974    fn linked_url_text_round_trips() {
9975        // A text node that is exactly a URL with a link mark pointing to the
9976        // same URL must round-trip as a single text node with a link mark
9977        // (no inlineCard, no lost/split content).
9978        let adf_json = r#"{
9979            "version": 1,
9980            "type": "doc",
9981            "content": [{
9982                "type": "paragraph",
9983                "content": [{
9984                    "type": "text",
9985                    "text": "https://example.com",
9986                    "marks": [{"type": "link", "attrs": {"href": "https://example.com"}}]
9987                }]
9988            }]
9989        }"#;
9990        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9991        let jfm = adf_to_markdown(&adf).unwrap();
9992        let roundtripped = markdown_to_adf(&jfm).unwrap();
9993        let content = roundtripped.content[0].content.as_ref().unwrap();
9994        assert_eq!(content.len(), 1);
9995        assert_eq!(content[0].node_type, "text");
9996        assert_eq!(content[0].text.as_deref(), Some("https://example.com"));
9997        let mark = &content[0].marks.as_ref().unwrap()[0];
9998        assert_eq!(mark.mark_type, "link");
9999        assert_eq!(mark.attrs.as_ref().unwrap()["href"], "https://example.com");
10000    }
10001
10002    // ── Issue #493: bracket-link ambiguity ─────────────────────────────
10003
10004    #[test]
10005    fn escape_link_brackets_unit() {
10006        assert_eq!(escape_link_brackets("hello"), "hello");
10007        assert_eq!(escape_link_brackets("["), "\\[");
10008        assert_eq!(escape_link_brackets("]"), "\\]");
10009        assert_eq!(escape_link_brackets("[PROJ-456]"), "\\[PROJ-456\\]");
10010        assert_eq!(escape_link_brackets("a[b]c"), "a\\[b\\]c");
10011    }
10012
10013    #[test]
10014    fn bracket_text_with_link_mark_escapes_brackets() {
10015        // A text node whose content is "[" with a link mark should escape
10016        // the bracket so it does not create ambiguous markdown link syntax.
10017        let doc = AdfDocument {
10018            version: 1,
10019            doc_type: "doc".to_string(),
10020            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10021                "[",
10022                vec![AdfMark::link("https://example.com")],
10023            )])],
10024        };
10025        let md = adf_to_markdown(&doc).unwrap();
10026        assert_eq!(md.trim(), "[\\[](https://example.com)");
10027    }
10028
10029    #[test]
10030    fn bracket_text_with_link_mark_round_trips() {
10031        // Issue #493 reproducer: adjacent text nodes sharing a link mark
10032        // where the first node's content is "[".
10033        let adf_json = r#"{
10034            "type": "doc",
10035            "version": 1,
10036            "content": [{
10037                "type": "paragraph",
10038                "content": [
10039                    {
10040                        "type": "text",
10041                        "text": "[",
10042                        "marks": [{"type": "link", "attrs": {"href": "https://example.com/ticket/123"}}]
10043                    },
10044                    {
10045                        "type": "text",
10046                        "text": "PROJ-456] Fix the auth bug",
10047                        "marks": [
10048                            {"type": "underline"},
10049                            {"type": "link", "attrs": {"href": "https://example.com/ticket/123"}}
10050                        ]
10051                    }
10052                ]
10053            }]
10054        }"#;
10055        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10056        let jfm = adf_to_markdown(&adf).unwrap();
10057
10058        // The markdown should contain escaped brackets inside the link
10059        assert!(jfm.contains("\\["), "opening bracket should be escaped");
10060
10061        // Round-trip: both text nodes must survive with link marks
10062        let rt = markdown_to_adf(&jfm).unwrap();
10063        let content = rt.content[0].content.as_ref().unwrap();
10064
10065        // All text nodes that were part of the link must still carry a link mark
10066        let link_nodes: Vec<_> = content
10067            .iter()
10068            .filter(|n| {
10069                n.marks
10070                    .as_ref()
10071                    .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "link"))
10072            })
10073            .collect();
10074        assert!(
10075            !link_nodes.is_empty(),
10076            "link mark must be preserved on round-trip"
10077        );
10078
10079        // The combined text across all nodes should contain the original content
10080        let all_text: String = content.iter().filter_map(|n| n.text.as_deref()).collect();
10081        assert!(
10082            all_text.contains('['),
10083            "literal '[' must survive round-trip"
10084        );
10085        assert!(
10086            all_text.contains("PROJ-456]"),
10087            "continuation text must survive round-trip"
10088        );
10089    }
10090
10091    #[test]
10092    fn closing_bracket_in_link_text_round_trips() {
10093        // A text node containing "]" inside a link should be escaped and
10094        // survive round-trip without breaking the link syntax.
10095        let doc = AdfDocument {
10096            version: 1,
10097            doc_type: "doc".to_string(),
10098            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10099                "item]",
10100                vec![AdfMark::link("https://example.com")],
10101            )])],
10102        };
10103        let md = adf_to_markdown(&doc).unwrap();
10104        assert_eq!(md.trim(), "[item\\]](https://example.com)");
10105
10106        let rt = markdown_to_adf(&md).unwrap();
10107        let content = rt.content[0].content.as_ref().unwrap();
10108        assert_eq!(content[0].text.as_deref(), Some("item]"));
10109        assert!(content[0]
10110            .marks
10111            .as_ref()
10112            .unwrap()
10113            .iter()
10114            .any(|m| m.mark_type == "link"));
10115    }
10116
10117    #[test]
10118    fn brackets_in_link_text_round_trip() {
10119        // Text containing both [ and ] inside a link should round-trip.
10120        let doc = AdfDocument {
10121            version: 1,
10122            doc_type: "doc".to_string(),
10123            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10124                "[PROJ-123]",
10125                vec![AdfMark::link("https://example.com")],
10126            )])],
10127        };
10128        let md = adf_to_markdown(&doc).unwrap();
10129        assert_eq!(md.trim(), "[\\[PROJ-123\\]](https://example.com)");
10130
10131        let rt = markdown_to_adf(&md).unwrap();
10132        let content = rt.content[0].content.as_ref().unwrap();
10133        assert_eq!(content[0].text.as_deref(), Some("[PROJ-123]"));
10134        assert!(content[0]
10135            .marks
10136            .as_ref()
10137            .unwrap()
10138            .iter()
10139            .any(|m| m.mark_type == "link"));
10140    }
10141
10142    #[test]
10143    fn plain_text_brackets_not_escaped() {
10144        // Brackets in plain text (no link mark) must NOT be escaped.
10145        let doc = AdfDocument {
10146            version: 1,
10147            doc_type: "doc".to_string(),
10148            content: vec![AdfNode::paragraph(vec![AdfNode::text(
10149                "see [PROJ-123] for details",
10150            )])],
10151        };
10152        let md = adf_to_markdown(&doc).unwrap();
10153        assert_eq!(md.trim(), "see [PROJ-123] for details");
10154    }
10155
10156    #[test]
10157    fn link_with_no_brackets_unchanged() {
10158        // A normal link with no brackets in the text should be unaffected.
10159        let doc = AdfDocument {
10160            version: 1,
10161            doc_type: "doc".to_string(),
10162            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10163                "click here",
10164                vec![AdfMark::link("https://example.com")],
10165            )])],
10166        };
10167        let md = adf_to_markdown(&doc).unwrap();
10168        assert_eq!(md.trim(), "[click here](https://example.com)");
10169    }
10170
10171    // ── Issue #551: URL brackets in link-marked text round-trip ────────
10172
10173    #[test]
10174    fn url_with_brackets_as_link_text_round_trips() {
10175        // Issue #551: a text node whose content is a URL containing square
10176        // brackets and which carries a link mark must round-trip verbatim.
10177        // Previously the URL-as-link-text fast path preserved `\[` and `\]`
10178        // escapes in the emitted text, corrupting the text content.
10179        let href = "https://example.com/dashboard?filter[0]=active&filter[1]=pending";
10180        let doc = AdfDocument {
10181            version: 1,
10182            doc_type: "doc".to_string(),
10183            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10184                href,
10185                vec![AdfMark::link(href)],
10186            )])],
10187        };
10188        let md = adf_to_markdown(&doc).unwrap();
10189        let rt = markdown_to_adf(&md).unwrap();
10190        let content = rt.content[0].content.as_ref().unwrap();
10191        assert_eq!(content.len(), 1);
10192        assert_eq!(content[0].node_type, "text");
10193        assert_eq!(content[0].text.as_deref(), Some(href));
10194        let mark = &content[0].marks.as_ref().unwrap()[0];
10195        assert_eq!(mark.mark_type, "link");
10196        assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10197    }
10198
10199    #[test]
10200    fn url_with_brackets_embedded_in_link_text_round_trips() {
10201        // Issue #551 updated reproducer: a link-marked text node containing
10202        // both prose and an embedded URL with brackets must round-trip
10203        // without the embedded URL being detected as a bare-URL inlineCard
10204        // or the brackets terminating the link syntax early.  This mirrors
10205        // the comment reproducer which uses an ellipsis character between
10206        // the brackets and a distinct href value.
10207        let href = "https://example.com/logs?query=service%20environment%20data&from=100&to=200";
10208        let text =
10209            "See the logs: https://example.com/logs?query=service[\u{2026}]data&from=100&to=200";
10210        let doc = AdfDocument {
10211            version: 1,
10212            doc_type: "doc".to_string(),
10213            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10214                text,
10215                vec![AdfMark::link(href)],
10216            )])],
10217        };
10218        let md = adf_to_markdown(&doc).unwrap();
10219        let rt = markdown_to_adf(&md).unwrap();
10220        let content = rt.content[0].content.as_ref().unwrap();
10221        assert_eq!(content.len(), 1, "content split unexpectedly: {content:?}");
10222        assert_eq!(content[0].node_type, "text");
10223        assert_eq!(content[0].text.as_deref(), Some(text));
10224        let mark = &content[0].marks.as_ref().unwrap()[0];
10225        assert_eq!(mark.mark_type, "link");
10226        assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10227    }
10228
10229    #[test]
10230    fn url_with_brackets_plain_text_round_trips() {
10231        // Issue #551 original reproducer: plain text with an embedded URL
10232        // that contains square brackets must round-trip verbatim.
10233        let text =
10234            "See the dashboard: https://example.com/dashboard?filter[0]=active&filter[1]=pending";
10235        let doc = AdfDocument {
10236            version: 1,
10237            doc_type: "doc".to_string(),
10238            content: vec![AdfNode::paragraph(vec![AdfNode::text(text)])],
10239        };
10240        let md = adf_to_markdown(&doc).unwrap();
10241        let rt = markdown_to_adf(&md).unwrap();
10242        let content = rt.content[0].content.as_ref().unwrap();
10243        assert_eq!(content.len(), 1);
10244        assert_eq!(content[0].node_type, "text");
10245        assert_eq!(content[0].text.as_deref(), Some(text));
10246        assert!(content[0].marks.is_none());
10247    }
10248
10249    #[test]
10250    fn url_with_link_mark_embedded_no_brackets_round_trips() {
10251        // Regression guard: embedding a bare URL inside link-marked text
10252        // (no brackets) must not create an inlineCard on round-trip.
10253        let href = "https://example.com/";
10254        let text = "See https://example.com/ for more";
10255        let doc = AdfDocument {
10256            version: 1,
10257            doc_type: "doc".to_string(),
10258            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10259                text,
10260                vec![AdfMark::link(href)],
10261            )])],
10262        };
10263        let md = adf_to_markdown(&doc).unwrap();
10264        let rt = markdown_to_adf(&md).unwrap();
10265        let content = rt.content[0].content.as_ref().unwrap();
10266        assert_eq!(content.len(), 1);
10267        assert_eq!(content[0].node_type, "text");
10268        assert_eq!(content[0].text.as_deref(), Some(text));
10269        let mark = &content[0].marks.as_ref().unwrap()[0];
10270        assert_eq!(mark.mark_type, "link");
10271        assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10272    }
10273
10274    #[test]
10275    fn nested_brackets_in_link_text_round_trip() {
10276        // Regression guard: nested brackets in link-marked text must
10277        // round-trip without corrupting the content.
10278        let href = "https://x.com";
10279        let text = "foo [a[b]c] bar";
10280        let doc = AdfDocument {
10281            version: 1,
10282            doc_type: "doc".to_string(),
10283            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10284                text,
10285                vec![AdfMark::link(href)],
10286            )])],
10287        };
10288        let md = adf_to_markdown(&doc).unwrap();
10289        let rt = markdown_to_adf(&md).unwrap();
10290        let content = rt.content[0].content.as_ref().unwrap();
10291        assert_eq!(content.len(), 1);
10292        assert_eq!(content[0].node_type, "text");
10293        assert_eq!(content[0].text.as_deref(), Some(text));
10294    }
10295
10296    #[test]
10297    fn bracket_url_bracket_in_link_text_round_trips() {
10298        // Regression guard: a link-marked text containing brackets on both
10299        // sides of an embedded URL (with brackets of its own) must
10300        // round-trip intact.  This exercises interaction between the
10301        // URL-as-link-text fast path, bare-URL detection, and bracket
10302        // escape handling.
10303        let href = "https://y.com";
10304        let text = "[see https://x.com/a[0]=1 here]";
10305        let doc = AdfDocument {
10306            version: 1,
10307            doc_type: "doc".to_string(),
10308            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10309                text,
10310                vec![AdfMark::link(href)],
10311            )])],
10312        };
10313        let md = adf_to_markdown(&doc).unwrap();
10314        let rt = markdown_to_adf(&md).unwrap();
10315        let content = rt.content[0].content.as_ref().unwrap();
10316        assert_eq!(content.len(), 1);
10317        assert_eq!(content[0].node_type, "text");
10318        assert_eq!(content[0].text.as_deref(), Some(text));
10319        let mark = &content[0].marks.as_ref().unwrap()[0];
10320        assert_eq!(mark.mark_type, "link");
10321        assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10322    }
10323
10324    #[test]
10325    fn escape_bare_urls_applied_inside_link_text() {
10326        // White-box: when a text node carries a link mark, bare URLs in the
10327        // text must still be escaped with `\h` so the parser does not
10328        // auto-link them into an inlineCard inside the link.  Without this,
10329        // round-trip of link-marked prose containing an embedded URL
10330        // silently corrupts on re-parse (issue #551).
10331        let doc = AdfDocument {
10332            version: 1,
10333            doc_type: "doc".to_string(),
10334            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10335                "See https://example.com/",
10336                vec![AdfMark::link("https://target.example.com/")],
10337            )])],
10338        };
10339        let md = adf_to_markdown(&doc).unwrap();
10340        assert!(
10341            md.contains(r"\https://example.com/"),
10342            "bare URL inside link text must be escaped, got: {md}"
10343        );
10344    }
10345
10346    #[test]
10347    fn inline_card_still_round_trips() {
10348        // An actual inlineCard node should still round-trip correctly
10349        // (it uses :card[url] syntax, not bare URL).
10350        let adf_json = r#"{
10351            "version": 1,
10352            "type": "doc",
10353            "content": [{
10354                "type": "paragraph",
10355                "content": [
10356                    {"type": "inlineCard", "attrs": {"url": "https://example.com/page"}}
10357                ]
10358            }]
10359        }"#;
10360        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10361        let jfm = adf_to_markdown(&adf).unwrap();
10362        assert!(jfm.contains(":card[https://example.com/page]"));
10363        let roundtripped = markdown_to_adf(&jfm).unwrap();
10364        let content = roundtripped.content[0].content.as_ref().unwrap();
10365        assert_eq!(content[0].node_type, "inlineCard");
10366        assert_eq!(
10367            content[0].attrs.as_ref().unwrap()["url"],
10368            "https://example.com/page"
10369        );
10370    }
10371
10372    // ── Issue #553: inlineCard round-trip with problematic URLs ───────
10373
10374    #[test]
10375    fn url_safe_in_bracket_content_balanced() {
10376        // Balanced brackets — depth never returns to zero mid-string.
10377        assert!(url_safe_in_bracket_content("https://example.com"));
10378        assert!(url_safe_in_bracket_content("https://example.com/[id]"));
10379        assert!(url_safe_in_bracket_content("a[b[c]d]e"));
10380        assert!(url_safe_in_bracket_content(""));
10381    }
10382
10383    #[test]
10384    fn url_safe_in_bracket_content_unbalanced() {
10385        // A `]` with no prior `[` would close `:card[...]` early.
10386        assert!(!url_safe_in_bracket_content("a]b"));
10387        assert!(!url_safe_in_bracket_content("https://example.com/path]end"));
10388        // Embedded newline breaks inline directive parsing.
10389        assert!(!url_safe_in_bracket_content("a\nb"));
10390    }
10391
10392    #[test]
10393    fn inline_card_url_with_closing_bracket_round_trip() {
10394        // Issue #553 defensive fix: a URL that contains `]` (unbalanced) must
10395        // round-trip without truncation.  The renderer must switch to the
10396        // quoted attribute form `:card[]{url="..."}` so the parser's
10397        // depth-based bracket matcher does not terminate the directive early.
10398        let adf_json = r#"{
10399            "version": 1,
10400            "type": "doc",
10401            "content": [{
10402                "type": "paragraph",
10403                "content": [
10404                    {"type": "text", "text": "See: "},
10405                    {"type": "inlineCard", "attrs": {"url": "https://example.com/path]end/?q=1"}}
10406                ]
10407            }]
10408        }"#;
10409        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10410        let jfm = adf_to_markdown(&adf).unwrap();
10411        assert!(
10412            jfm.contains(r#":card[]{url="https://example.com/path]end/?q=1"}"#),
10413            "expected attr-form for URL with `]`, got: {jfm}"
10414        );
10415        let rt = markdown_to_adf(&jfm).unwrap();
10416        let content = rt.content[0].content.as_ref().unwrap();
10417        assert_eq!(content.len(), 2, "expected 2 inline nodes, got {content:?}");
10418        assert_eq!(content[0].node_type, "text");
10419        assert_eq!(content[0].text.as_deref(), Some("See: "));
10420        assert_eq!(content[1].node_type, "inlineCard");
10421        assert_eq!(
10422            content[1].attrs.as_ref().unwrap()["url"],
10423            "https://example.com/path]end/?q=1"
10424        );
10425    }
10426
10427    #[test]
10428    fn inline_card_url_with_closing_bracket_preserves_local_id() {
10429        // Attr-form `:card[]{url=... localId=...}` must preserve localId too.
10430        let adf_json = r#"{
10431            "version": 1,
10432            "type": "doc",
10433            "content": [{
10434                "type": "paragraph",
10435                "content": [
10436                    {"type": "inlineCard", "attrs": {
10437                        "url": "https://example.com/a]b",
10438                        "localId": "c-77"
10439                    }}
10440                ]
10441            }]
10442        }"#;
10443        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10444        let jfm = adf_to_markdown(&adf).unwrap();
10445        assert!(
10446            jfm.contains(r#"url="https://example.com/a]b""#),
10447            "jfm: {jfm}"
10448        );
10449        assert!(jfm.contains("localId=c-77"), "jfm: {jfm}");
10450        let rt = markdown_to_adf(&jfm).unwrap();
10451        let card = &rt.content[0].content.as_ref().unwrap()[0];
10452        assert_eq!(card.node_type, "inlineCard");
10453        assert_eq!(
10454            card.attrs.as_ref().unwrap()["url"],
10455            "https://example.com/a]b"
10456        );
10457        assert_eq!(card.attrs.as_ref().unwrap()["localId"], "c-77");
10458    }
10459
10460    #[test]
10461    fn block_card_url_with_closing_bracket_round_trip() {
10462        // Same defensive fix applied to the leaf directive `::card`.
10463        let adf_json = r#"{
10464            "version": 1,
10465            "type": "doc",
10466            "content": [
10467                {"type": "blockCard", "attrs": {"url": "https://example.com/path]end"}}
10468            ]
10469        }"#;
10470        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10471        let jfm = adf_to_markdown(&adf).unwrap();
10472        assert!(
10473            jfm.contains(r#"::card[]{url="https://example.com/path]end"}"#),
10474            "expected attr-form for blockCard with `]`, got: {jfm}"
10475        );
10476        let rt = markdown_to_adf(&jfm).unwrap();
10477        assert_eq!(rt.content[0].node_type, "blockCard");
10478        assert_eq!(
10479            rt.content[0].attrs.as_ref().unwrap()["url"],
10480            "https://example.com/path]end"
10481        );
10482    }
10483
10484    #[test]
10485    fn block_card_attr_form_parses_without_renderer() {
10486        // Directly parsing `::card[]{url="..."}` exercises the attr-URL
10487        // fallback in the leaf-directive dispatcher (covers the `url` lookup
10488        // path independently of the ADF→JFM renderer).
10489        let doc = markdown_to_adf(r#"::card[]{url="https://example.com/a"}"#).unwrap();
10490        assert_eq!(doc.content[0].node_type, "blockCard");
10491        assert_eq!(
10492            doc.content[0].attrs.as_ref().unwrap()["url"],
10493            "https://example.com/a"
10494        );
10495    }
10496
10497    #[test]
10498    fn block_card_attr_form_url_overrides_content() {
10499        // When both bracket-content and `url=` attribute are present on
10500        // `::card`, the attribute wins.  Mirrors the inline-directive
10501        // behaviour and keeps hand-edited JFM forgiving.
10502        let doc =
10503            markdown_to_adf(r#"::card[https://old.example.com]{url="https://new.example.com"}"#)
10504                .unwrap();
10505        assert_eq!(doc.content[0].node_type, "blockCard");
10506        assert_eq!(
10507            doc.content[0].attrs.as_ref().unwrap()["url"],
10508            "https://new.example.com"
10509        );
10510    }
10511
10512    #[test]
10513    fn block_card_attr_form_with_layout_and_width() {
10514        // Attr-URL form combined with layout/width attrs — ensures all
10515        // sibling attrs still pass through after the URL lookup.
10516        let doc =
10517            markdown_to_adf(r#"::card[]{url="https://example.com/a]b" layout=wide width=80}"#)
10518                .unwrap();
10519        let attrs = doc.content[0].attrs.as_ref().unwrap();
10520        assert_eq!(attrs["url"], "https://example.com/a]b");
10521        assert_eq!(attrs["layout"], "wide");
10522        assert_eq!(attrs["width"], 80);
10523    }
10524
10525    #[test]
10526    fn inline_card_issue_553_reproducer() {
10527        // Verbatim reproducer from issue #553: an inlineCard in a paragraph
10528        // with preceding text must round-trip as an inlineCard, not degrade to
10529        // a text node with a link mark.
10530        let adf_json = r#"{
10531            "version": 1,
10532            "type": "doc",
10533            "content": [{
10534                "type": "paragraph",
10535                "content": [
10536                    {"type": "text", "text": "See the related page: "},
10537                    {"type": "inlineCard", "attrs": {
10538                        "url": "https://example.atlassian.net/wiki/spaces/ENG/pages/12345678"
10539                    }}
10540                ]
10541            }]
10542        }"#;
10543        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10544        let jfm = adf_to_markdown(&adf).unwrap();
10545        let rt = markdown_to_adf(&jfm).unwrap();
10546        let content = rt.content[0].content.as_ref().unwrap();
10547        assert_eq!(content.len(), 2);
10548        assert_eq!(content[0].node_type, "text");
10549        assert_eq!(content[1].node_type, "inlineCard");
10550        assert_eq!(
10551            content[1].attrs.as_ref().unwrap()["url"],
10552            "https://example.atlassian.net/wiki/spaces/ENG/pages/12345678"
10553        );
10554    }
10555
10556    #[test]
10557    fn inline_card_attr_form_parses_even_without_renderer() {
10558        // Directly parsing `:card[]{url="..."}` should yield an inlineCard.
10559        let doc = markdown_to_adf(r#":card[]{url="https://example.com/a"}"#).unwrap();
10560        let node = &doc.content[0].content.as_ref().unwrap()[0];
10561        assert_eq!(node.node_type, "inlineCard");
10562        assert_eq!(node.attrs.as_ref().unwrap()["url"], "https://example.com/a");
10563    }
10564
10565    #[test]
10566    fn inline_card_attr_form_url_overrides_content() {
10567        // When both bracket-content and `url=` attr are present, attr wins.
10568        // This keeps the parser forgiving of hand-edited JFM where a user
10569        // copied an old bracket form but added attrs.
10570        let doc =
10571            markdown_to_adf(r#":card[https://old.example.com]{url="https://new.example.com"}"#)
10572                .unwrap();
10573        let node = &doc.content[0].content.as_ref().unwrap()[0];
10574        assert_eq!(node.node_type, "inlineCard");
10575        assert_eq!(
10576            node.attrs.as_ref().unwrap()["url"],
10577            "https://new.example.com"
10578        );
10579    }
10580
10581    // ── Issue #553 (updated): mark-wrapped URL must not become inlineCard ──
10582
10583    #[test]
10584    fn url_with_link_and_underline_marks_round_trip() {
10585        // Issue #553 (updated reproducer): a `text` node whose content is a
10586        // URL and that carries both `link` and `underline` marks must round-
10587        // trip as text+marks, not be promoted to an `inlineCard`.
10588        let adf_json = r#"{
10589            "version": 1,
10590            "type": "doc",
10591            "content": [{
10592                "type": "paragraph",
10593                "content": [
10594                    {"type": "text", "text": "See results at: "},
10595                    {"type": "text",
10596                     "text": "https://example.com/projects/abc123/analytics",
10597                     "marks": [
10598                        {"type": "link", "attrs": {"href": "https://example.com/projects/abc123/analytics"}},
10599                        {"type": "underline"}
10600                     ]},
10601                    {"type": "text", "text": " for details."}
10602                ]
10603            }]
10604        }"#;
10605        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10606        let jfm = adf_to_markdown(&adf).unwrap();
10607        let rt = markdown_to_adf(&jfm).unwrap();
10608        let content = rt.content[0].content.as_ref().unwrap();
10609        assert_eq!(
10610            content.len(),
10611            3,
10612            "expected 3 inline nodes, got: {content:?}"
10613        );
10614        assert_eq!(content[0].node_type, "text");
10615        assert_eq!(
10616            content[1].node_type, "text",
10617            "must stay text, not inlineCard"
10618        );
10619        assert_eq!(
10620            content[1].text.as_deref(),
10621            Some("https://example.com/projects/abc123/analytics")
10622        );
10623        let mark_types: Vec<&str> = content[1]
10624            .marks
10625            .as_deref()
10626            .unwrap_or(&[])
10627            .iter()
10628            .map(|m| m.mark_type.as_str())
10629            .collect();
10630        assert_eq!(mark_types, vec!["link", "underline"], "marks lost");
10631        assert_eq!(content[2].node_type, "text");
10632    }
10633
10634    #[test]
10635    fn url_inside_bracketed_span_stays_text() {
10636        // `[URL]{underline}` in JFM means "underline this URL text", not
10637        // "create a smart link that's underlined".  The nested parse_inline
10638        // call must not auto-promote the bare URL to an inlineCard.
10639        let doc = markdown_to_adf("[https://example.com]{underline}").unwrap();
10640        let node = &doc.content[0].content.as_ref().unwrap()[0];
10641        assert_eq!(node.node_type, "text");
10642        assert_eq!(node.text.as_deref(), Some("https://example.com"));
10643        let mark_types: Vec<&str> = node
10644            .marks
10645            .as_deref()
10646            .unwrap_or(&[])
10647            .iter()
10648            .map(|m| m.mark_type.as_str())
10649            .collect();
10650        assert_eq!(mark_types, vec!["underline"]);
10651    }
10652
10653    #[test]
10654    fn url_inside_emphasis_stays_text() {
10655        // Bold, italic, and strike-wrapped URLs should remain as text nodes,
10656        // not get promoted to inlineCards by the nested inline parser.
10657        for (md, mark) in [
10658            ("**https://example.com**", "strong"),
10659            ("*https://example.com*", "em"),
10660            ("~~https://example.com~~", "strike"),
10661        ] {
10662            let doc = markdown_to_adf(md).unwrap();
10663            let node = &doc.content[0].content.as_ref().unwrap()[0];
10664            assert_eq!(node.node_type, "text", "md={md}: must be text");
10665            assert_eq!(node.text.as_deref(), Some("https://example.com"));
10666            let mark_types: Vec<&str> = node
10667                .marks
10668                .as_deref()
10669                .unwrap_or(&[])
10670                .iter()
10671                .map(|m| m.mark_type.as_str())
10672                .collect();
10673            assert_eq!(mark_types, vec![mark], "md={md}: wrong marks");
10674        }
10675    }
10676
10677    #[test]
10678    fn url_inside_span_directive_stays_text() {
10679        // `:span[URL]{color=red}` should not promote the URL to an inlineCard.
10680        let doc = markdown_to_adf(":span[https://example.com]{color=red}").unwrap();
10681        let node = &doc.content[0].content.as_ref().unwrap()[0];
10682        assert_eq!(node.node_type, "text");
10683        assert_eq!(node.text.as_deref(), Some("https://example.com"));
10684        let mark = &node.marks.as_ref().unwrap()[0];
10685        assert_eq!(mark.mark_type, "textColor");
10686    }
10687
10688    #[test]
10689    fn url_as_link_text_with_underline_after_link_mark_order() {
10690        // Reverse mark order — underline appears BEFORE link in the ADF array.
10691        // The JFM form is `[[text](url)]{underline}`; the nested parser must
10692        // still keep the URL as plain text.
10693        let adf_json = r#"{
10694            "version": 1,
10695            "type": "doc",
10696            "content": [{
10697                "type": "paragraph",
10698                "content": [
10699                    {"type": "text",
10700                     "text": "https://example.com",
10701                     "marks": [
10702                        {"type": "underline"},
10703                        {"type": "link", "attrs": {"href": "https://example.com"}}
10704                     ]}
10705                ]
10706            }]
10707        }"#;
10708        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10709        let jfm = adf_to_markdown(&adf).unwrap();
10710        let rt = markdown_to_adf(&jfm).unwrap();
10711        let node = &rt.content[0].content.as_ref().unwrap()[0];
10712        assert_eq!(node.node_type, "text", "must stay text, got: {node:?}");
10713        assert_eq!(node.text.as_deref(), Some("https://example.com"));
10714        let mark_types: Vec<&str> = node
10715            .marks
10716            .as_deref()
10717            .unwrap_or(&[])
10718            .iter()
10719            .map(|m| m.mark_type.as_str())
10720            .collect();
10721        assert_eq!(mark_types, vec!["underline", "link"]);
10722    }
10723
10724    #[test]
10725    fn bare_url_at_top_level_still_becomes_inline_card() {
10726        // Regression guard: the suppression only applies inside mark-wrapping
10727        // constructs.  A bare URL in ordinary paragraph text must still be
10728        // detected and promoted to an inlineCard.
10729        let doc = markdown_to_adf("Visit https://example.com today").unwrap();
10730        let content = doc.content[0].content.as_ref().unwrap();
10731        assert_eq!(content.len(), 3);
10732        assert_eq!(content[0].node_type, "text");
10733        assert_eq!(content[1].node_type, "inlineCard");
10734        assert_eq!(
10735            content[1].attrs.as_ref().unwrap()["url"],
10736            "https://example.com"
10737        );
10738        assert_eq!(content[2].node_type, "text");
10739    }
10740
10741    // ── Block-level attribute marks (Tier 5/6) ───────────────────────
10742
10743    #[test]
10744    fn paragraph_align_center() {
10745        let md = "Centered text.\n{align=center}";
10746        let doc = markdown_to_adf(md).unwrap();
10747        let marks = doc.content[0].marks.as_ref().unwrap();
10748        assert_eq!(marks[0].mark_type, "alignment");
10749        assert_eq!(marks[0].attrs.as_ref().unwrap()["align"], "center");
10750    }
10751
10752    #[test]
10753    fn adf_alignment_to_markdown() {
10754        let mut node = AdfNode::paragraph(vec![AdfNode::text("Centered.")]);
10755        node.marks = Some(vec![AdfMark::alignment("center")]);
10756        let doc = AdfDocument {
10757            version: 1,
10758            doc_type: "doc".to_string(),
10759            content: vec![node],
10760        };
10761        let md = adf_to_markdown(&doc).unwrap();
10762        assert!(md.contains("Centered."));
10763        assert!(md.contains("{align=center}"));
10764    }
10765
10766    #[test]
10767    fn round_trip_alignment() {
10768        let md = "Centered.\n{align=center}\n";
10769        let doc = markdown_to_adf(md).unwrap();
10770        let result = adf_to_markdown(&doc).unwrap();
10771        assert!(result.contains("{align=center}"));
10772    }
10773
10774    #[test]
10775    fn paragraph_indent() {
10776        let md = "Indented.\n{indent=2}";
10777        let doc = markdown_to_adf(md).unwrap();
10778        let marks = doc.content[0].marks.as_ref().unwrap();
10779        assert_eq!(marks[0].mark_type, "indentation");
10780        assert_eq!(marks[0].attrs.as_ref().unwrap()["level"], 2);
10781    }
10782
10783    #[test]
10784    fn code_block_breakout() {
10785        let md = "```python\ndef f(): pass\n```\n{breakout=wide}";
10786        let doc = markdown_to_adf(md).unwrap();
10787        let marks = doc.content[0].marks.as_ref().unwrap();
10788        assert_eq!(marks[0].mark_type, "breakout");
10789        assert_eq!(marks[0].attrs.as_ref().unwrap()["mode"], "wide");
10790        assert!(marks[0].attrs.as_ref().unwrap().get("width").is_none());
10791    }
10792
10793    #[test]
10794    fn code_block_breakout_with_width() {
10795        let md = "```python\ndef f(): pass\n```\n{breakout=wide breakoutWidth=1200}";
10796        let doc = markdown_to_adf(md).unwrap();
10797        let marks = doc.content[0].marks.as_ref().unwrap();
10798        assert_eq!(marks[0].mark_type, "breakout");
10799        assert_eq!(marks[0].attrs.as_ref().unwrap()["mode"], "wide");
10800        assert_eq!(marks[0].attrs.as_ref().unwrap()["width"], 1200);
10801    }
10802
10803    #[test]
10804    fn adf_breakout_to_markdown() {
10805        let mut node = AdfNode::code_block(Some("python"), "pass");
10806        node.marks = Some(vec![AdfMark::breakout("wide", None)]);
10807        let doc = AdfDocument {
10808            version: 1,
10809            doc_type: "doc".to_string(),
10810            content: vec![node],
10811        };
10812        let md = adf_to_markdown(&doc).unwrap();
10813        assert!(md.contains("{breakout=wide}"));
10814        assert!(!md.contains("breakoutWidth"));
10815    }
10816
10817    #[test]
10818    fn adf_breakout_with_width_to_markdown() {
10819        let mut node = AdfNode::code_block(Some("python"), "pass");
10820        node.marks = Some(vec![AdfMark::breakout("wide", Some(1200))]);
10821        let doc = AdfDocument {
10822            version: 1,
10823            doc_type: "doc".to_string(),
10824            content: vec![node],
10825        };
10826        let md = adf_to_markdown(&doc).unwrap();
10827        assert!(md.contains("breakout=wide"));
10828        assert!(md.contains("breakoutWidth=1200"));
10829    }
10830
10831    #[test]
10832    fn breakout_width_round_trip() {
10833        let adf_json = r#"{"version":1,"type":"doc","content":[{
10834            "type":"codeBlock",
10835            "attrs":{"language":"text"},
10836            "marks":[{"type":"breakout","attrs":{"mode":"wide","width":1200}}],
10837            "content":[{"type":"text","text":"some code"}]
10838        }]}"#;
10839        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10840        let md = adf_to_markdown(&doc).unwrap();
10841        assert!(md.contains("breakout=wide"));
10842        assert!(md.contains("breakoutWidth=1200"));
10843        let round_tripped = markdown_to_adf(&md).unwrap();
10844        let marks = round_tripped.content[0].marks.as_ref().unwrap();
10845        let breakout = marks.iter().find(|m| m.mark_type == "breakout").unwrap();
10846        assert_eq!(breakout.attrs.as_ref().unwrap()["mode"], "wide");
10847        assert_eq!(breakout.attrs.as_ref().unwrap()["width"], 1200);
10848    }
10849
10850    // ── Attribute extensions — media & table (Tier 5) ────────────────
10851
10852    #[test]
10853    fn image_with_layout_attrs() {
10854        let doc = markdown_to_adf("![alt](url){layout=wide width=80}").unwrap();
10855        let node = &doc.content[0];
10856        assert_eq!(node.node_type, "mediaSingle");
10857        let attrs = node.attrs.as_ref().unwrap();
10858        assert_eq!(attrs["layout"], "wide");
10859        assert_eq!(attrs["width"], 80);
10860    }
10861
10862    #[test]
10863    fn adf_image_with_layout_to_markdown() {
10864        let mut node = AdfNode::media_single("url", Some("alt"));
10865        node.attrs.as_mut().unwrap()["layout"] = serde_json::json!("wide");
10866        node.attrs.as_mut().unwrap()["width"] = serde_json::json!(80);
10867        let doc = AdfDocument {
10868            version: 1,
10869            doc_type: "doc".to_string(),
10870            content: vec![node],
10871        };
10872        let md = adf_to_markdown(&doc).unwrap();
10873        assert!(md.contains("![alt](url){layout=wide width=80}"));
10874    }
10875
10876    #[test]
10877    fn table_with_layout_attrs() {
10878        let md = "| H |\n| --- |\n| C |\n{layout=wide numbered}";
10879        let doc = markdown_to_adf(md).unwrap();
10880        let table = &doc.content[0];
10881        assert_eq!(table.node_type, "table");
10882        let attrs = table.attrs.as_ref().unwrap();
10883        assert_eq!(attrs["layout"], "wide");
10884        assert_eq!(attrs["isNumberColumnEnabled"], true);
10885    }
10886
10887    #[test]
10888    fn adf_table_with_attrs_to_markdown() {
10889        let mut table = AdfNode::table(vec![
10890            AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
10891                AdfNode::text("H"),
10892            ])])]),
10893            AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
10894                AdfNode::text("C"),
10895            ])])]),
10896        ]);
10897        table.attrs = Some(serde_json::json!({"layout": "wide", "isNumberColumnEnabled": true}));
10898        let doc = AdfDocument {
10899            version: 1,
10900            doc_type: "doc".to_string(),
10901            content: vec![table],
10902        };
10903        let md = adf_to_markdown(&doc).unwrap();
10904        assert!(md.contains("{layout=wide numbered}"));
10905    }
10906
10907    // ── Attribute extensions — inline marks (Tier 5) ─────────────────
10908
10909    #[test]
10910    fn underline_bracketed_span() {
10911        let doc = markdown_to_adf("This is [underlined text]{underline} here.").unwrap();
10912        let content = doc.content[0].content.as_ref().unwrap();
10913        assert_eq!(content[1].text.as_deref(), Some("underlined text"));
10914        let marks = content[1].marks.as_ref().unwrap();
10915        assert_eq!(marks[0].mark_type, "underline");
10916    }
10917
10918    #[test]
10919    fn adf_underline_to_markdown() {
10920        let doc = AdfDocument {
10921            version: 1,
10922            doc_type: "doc".to_string(),
10923            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10924                "underlined",
10925                vec![AdfMark::underline()],
10926            )])],
10927        };
10928        let md = adf_to_markdown(&doc).unwrap();
10929        assert!(md.contains("[underlined]{underline}"));
10930    }
10931
10932    #[test]
10933    fn round_trip_underline() {
10934        let md = "This is [underlined text]{underline} here.\n";
10935        let doc = markdown_to_adf(md).unwrap();
10936        let result = adf_to_markdown(&doc).unwrap();
10937        assert!(result.contains("[underlined text]{underline}"));
10938    }
10939
10940    #[test]
10941    fn mark_ordering_underline_strong_preserved() {
10942        // Issue #383: mark ordering was non-deterministic
10943        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10944          {"type":"text","text":"bold and underlined","marks":[{"type":"underline"},{"type":"strong"}]}
10945        ]}]}"#;
10946        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10947        let md = adf_to_markdown(&doc).unwrap();
10948        let round_tripped = markdown_to_adf(&md).unwrap();
10949        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
10950        let mark_types: Vec<&str> = node
10951            .marks
10952            .as_ref()
10953            .unwrap()
10954            .iter()
10955            .map(|m| m.mark_type.as_str())
10956            .collect();
10957        assert_eq!(
10958            mark_types,
10959            vec!["underline", "strong"],
10960            "mark order should be preserved, got: {mark_types:?}"
10961        );
10962    }
10963
10964    #[test]
10965    fn mark_ordering_link_strong_preserved() {
10966        // Issue #403: link+strong mark order was swapped on round-trip
10967        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10968          {"type":"text","text":"bold link","marks":[
10969            {"type":"link","attrs":{"href":"https://example.com"}},
10970            {"type":"strong"}
10971          ]}
10972        ]}]}"#;
10973        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10974        let md = adf_to_markdown(&doc).unwrap();
10975        let round_tripped = markdown_to_adf(&md).unwrap();
10976        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
10977        let mark_types: Vec<&str> = node
10978            .marks
10979            .as_ref()
10980            .unwrap()
10981            .iter()
10982            .map(|m| m.mark_type.as_str())
10983            .collect();
10984        assert_eq!(
10985            mark_types,
10986            vec!["link", "strong"],
10987            "mark order should be preserved, got: {mark_types:?}"
10988        );
10989    }
10990
10991    #[test]
10992    fn mark_ordering_link_textcolor_preserved() {
10993        // Issue #403 comment: link+textColor mark order was swapped on round-trip
10994        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
10995          {"type":"text","text":"red link","marks":[
10996            {"type":"link","attrs":{"href":"https://example.com"}},
10997            {"type":"textColor","attrs":{"color":"#ff0000"}}
10998          ]}
10999        ]}]}"##;
11000        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11001        let md = adf_to_markdown(&doc).unwrap();
11002        let round_tripped = markdown_to_adf(&md).unwrap();
11003        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11004        let mark_types: Vec<&str> = node
11005            .marks
11006            .as_ref()
11007            .unwrap()
11008            .iter()
11009            .map(|m| m.mark_type.as_str())
11010            .collect();
11011        assert_eq!(
11012            mark_types,
11013            vec!["link", "textColor"],
11014            "mark order should be preserved, got: {mark_types:?}"
11015        );
11016    }
11017
11018    #[test]
11019    fn mark_ordering_link_em_preserved() {
11020        // Issue #403: link+em mark order should be preserved
11021        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11022          {"type":"text","text":"italic link","marks":[
11023            {"type":"link","attrs":{"href":"https://example.com"}},
11024            {"type":"em"}
11025          ]}
11026        ]}]}"#;
11027        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11028        let md = adf_to_markdown(&doc).unwrap();
11029        let round_tripped = markdown_to_adf(&md).unwrap();
11030        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11031        let mark_types: Vec<&str> = node
11032            .marks
11033            .as_ref()
11034            .unwrap()
11035            .iter()
11036            .map(|m| m.mark_type.as_str())
11037            .collect();
11038        assert_eq!(
11039            mark_types,
11040            vec!["link", "em"],
11041            "mark order should be preserved, got: {mark_types:?}"
11042        );
11043    }
11044
11045    #[test]
11046    fn mark_ordering_link_strike_preserved() {
11047        // Issue #403: link+strike mark order should be preserved
11048        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11049          {"type":"text","text":"struck link","marks":[
11050            {"type":"link","attrs":{"href":"https://example.com"}},
11051            {"type":"strike"}
11052          ]}
11053        ]}]}"#;
11054        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11055        let md = adf_to_markdown(&doc).unwrap();
11056        let round_tripped = markdown_to_adf(&md).unwrap();
11057        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11058        let mark_types: Vec<&str> = node
11059            .marks
11060            .as_ref()
11061            .unwrap()
11062            .iter()
11063            .map(|m| m.mark_type.as_str())
11064            .collect();
11065        assert_eq!(
11066            mark_types,
11067            vec!["link", "strike"],
11068            "mark order should be preserved, got: {mark_types:?}"
11069        );
11070    }
11071
11072    #[test]
11073    fn mark_ordering_strong_link_preserved() {
11074        // Issue #403: [strong, link] order must also be preserved (reverse direction)
11075        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11076          {"type":"text","text":"bold link","marks":[
11077            {"type":"strong"},
11078            {"type":"link","attrs":{"href":"https://example.com"}}
11079          ]}
11080        ]}]}"#;
11081        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11082        let md = adf_to_markdown(&doc).unwrap();
11083        let round_tripped = markdown_to_adf(&md).unwrap();
11084        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11085        let mark_types: Vec<&str> = node
11086            .marks
11087            .as_ref()
11088            .unwrap()
11089            .iter()
11090            .map(|m| m.mark_type.as_str())
11091            .collect();
11092        assert_eq!(
11093            mark_types,
11094            vec!["strong", "link"],
11095            "mark order should be preserved, got: {mark_types:?}"
11096        );
11097    }
11098
11099    #[test]
11100    fn mark_ordering_em_link_preserved() {
11101        // Issue #403: [em, link] order must also be preserved
11102        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11103          {"type":"text","text":"italic link","marks":[
11104            {"type":"em"},
11105            {"type":"link","attrs":{"href":"https://example.com"}}
11106          ]}
11107        ]}]}"#;
11108        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11109        let md = adf_to_markdown(&doc).unwrap();
11110        let round_tripped = markdown_to_adf(&md).unwrap();
11111        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11112        let mark_types: Vec<&str> = node
11113            .marks
11114            .as_ref()
11115            .unwrap()
11116            .iter()
11117            .map(|m| m.mark_type.as_str())
11118            .collect();
11119        assert_eq!(
11120            mark_types,
11121            vec!["em", "link"],
11122            "mark order should be preserved, got: {mark_types:?}"
11123        );
11124    }
11125
11126    #[test]
11127    fn mark_ordering_strike_link_preserved() {
11128        // Issue #403: [strike, link] order must also be preserved
11129        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11130          {"type":"text","text":"struck link","marks":[
11131            {"type":"strike"},
11132            {"type":"link","attrs":{"href":"https://example.com"}}
11133          ]}
11134        ]}]}"#;
11135        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11136        let md = adf_to_markdown(&doc).unwrap();
11137        let round_tripped = markdown_to_adf(&md).unwrap();
11138        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11139        let mark_types: Vec<&str> = node
11140            .marks
11141            .as_ref()
11142            .unwrap()
11143            .iter()
11144            .map(|m| m.mark_type.as_str())
11145            .collect();
11146        assert_eq!(
11147            mark_types,
11148            vec!["strike", "link"],
11149            "mark order should be preserved, got: {mark_types:?}"
11150        );
11151    }
11152
11153    #[test]
11154    fn mark_ordering_underline_link_preserved() {
11155        // Issue #403: [underline, link] order must be preserved
11156        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11157          {"type":"text","text":"click here","marks":[
11158            {"type":"underline"},
11159            {"type":"link","attrs":{"href":"https://example.com"}}
11160          ]}
11161        ]}]}"#;
11162        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11163        let md = adf_to_markdown(&doc).unwrap();
11164        let round_tripped = markdown_to_adf(&md).unwrap();
11165        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11166        let mark_types: Vec<&str> = node
11167            .marks
11168            .as_ref()
11169            .unwrap()
11170            .iter()
11171            .map(|m| m.mark_type.as_str())
11172            .collect();
11173        assert_eq!(
11174            mark_types,
11175            vec!["underline", "link"],
11176            "mark order should be preserved, got: {mark_types:?}"
11177        );
11178    }
11179
11180    #[test]
11181    fn mark_ordering_textcolor_link_preserved() {
11182        // Issue #403: [textColor, link] order must be preserved
11183        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11184          {"type":"text","text":"red link","marks":[
11185            {"type":"textColor","attrs":{"color":"#ff0000"}},
11186            {"type":"link","attrs":{"href":"https://example.com"}}
11187          ]}
11188        ]}]}"##;
11189        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11190        let md = adf_to_markdown(&doc).unwrap();
11191        let round_tripped = markdown_to_adf(&md).unwrap();
11192        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11193        let mark_types: Vec<&str> = node
11194            .marks
11195            .as_ref()
11196            .unwrap()
11197            .iter()
11198            .map(|m| m.mark_type.as_str())
11199            .collect();
11200        assert_eq!(
11201            mark_types,
11202            vec!["textColor", "link"],
11203            "mark order should be preserved, got: {mark_types:?}"
11204        );
11205    }
11206
11207    #[test]
11208    fn mark_ordering_link_underline_preserved() {
11209        // Issue #403: [link, underline] order must be preserved (link wraps bracketed span)
11210        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11211          {"type":"text","text":"click here","marks":[
11212            {"type":"link","attrs":{"href":"https://example.com"}},
11213            {"type":"underline"}
11214          ]}
11215        ]}]}"#;
11216        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11217        let md = adf_to_markdown(&doc).unwrap();
11218        // Link should wrap the underline bracketed span: [[click here]{underline}](url)
11219        assert!(
11220            md.contains("](https://example.com)"),
11221            "should have link: {md}"
11222        );
11223        assert!(md.contains("underline"), "should have underline: {md}");
11224        let round_tripped = markdown_to_adf(&md).unwrap();
11225        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11226        let mark_types: Vec<&str> = node
11227            .marks
11228            .as_ref()
11229            .unwrap()
11230            .iter()
11231            .map(|m| m.mark_type.as_str())
11232            .collect();
11233        assert_eq!(
11234            mark_types,
11235            vec!["link", "underline"],
11236            "mark order should be preserved, got: {mark_types:?}"
11237        );
11238    }
11239
11240    #[test]
11241    fn mark_ordering_underline_strong_link_preserved() {
11242        // Issue #491: [underline, strong, link] reordered to [strong, underline, link]
11243        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11244          {"type":"text","text":"bold underlined link","marks":[
11245            {"type":"underline"},
11246            {"type":"strong"},
11247            {"type":"link","attrs":{"href":"https://example.com/page"}}
11248          ]}
11249        ]}]}"#;
11250        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11251        let md = adf_to_markdown(&doc).unwrap();
11252        let round_tripped = markdown_to_adf(&md).unwrap();
11253        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11254        let mark_types: Vec<&str> = node
11255            .marks
11256            .as_ref()
11257            .unwrap()
11258            .iter()
11259            .map(|m| m.mark_type.as_str())
11260            .collect();
11261        assert_eq!(
11262            mark_types,
11263            vec!["underline", "strong", "link"],
11264            "mark order should be preserved, got: {mark_types:?}"
11265        );
11266    }
11267
11268    #[test]
11269    fn mark_ordering_strong_underline_link_preserved() {
11270        // Issue #491: verify [strong, underline, link] is preserved
11271        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11272          {"type":"text","text":"bold underlined link","marks":[
11273            {"type":"strong"},
11274            {"type":"underline"},
11275            {"type":"link","attrs":{"href":"https://example.com/page"}}
11276          ]}
11277        ]}]}"#;
11278        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11279        let md = adf_to_markdown(&doc).unwrap();
11280        let round_tripped = markdown_to_adf(&md).unwrap();
11281        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11282        let mark_types: Vec<&str> = node
11283            .marks
11284            .as_ref()
11285            .unwrap()
11286            .iter()
11287            .map(|m| m.mark_type.as_str())
11288            .collect();
11289        assert_eq!(
11290            mark_types,
11291            vec!["strong", "underline", "link"],
11292            "mark order should be preserved, got: {mark_types:?}"
11293        );
11294    }
11295
11296    #[test]
11297    fn mark_ordering_underline_em_link_preserved() {
11298        // Issue #491: verify [underline, em, link] is preserved
11299        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11300          {"type":"text","text":"italic underlined link","marks":[
11301            {"type":"underline"},
11302            {"type":"em"},
11303            {"type":"link","attrs":{"href":"https://example.com/page"}}
11304          ]}
11305        ]}]}"#;
11306        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11307        let md = adf_to_markdown(&doc).unwrap();
11308        let round_tripped = markdown_to_adf(&md).unwrap();
11309        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11310        let mark_types: Vec<&str> = node
11311            .marks
11312            .as_ref()
11313            .unwrap()
11314            .iter()
11315            .map(|m| m.mark_type.as_str())
11316            .collect();
11317        assert_eq!(
11318            mark_types,
11319            vec!["underline", "em", "link"],
11320            "mark order should be preserved, got: {mark_types:?}"
11321        );
11322    }
11323
11324    #[test]
11325    fn mark_ordering_underline_strike_link_preserved() {
11326        // Issue #491: verify [underline, strike, link] is preserved
11327        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11328          {"type":"text","text":"struck underlined link","marks":[
11329            {"type":"underline"},
11330            {"type":"strike"},
11331            {"type":"link","attrs":{"href":"https://example.com/page"}}
11332          ]}
11333        ]}]}"#;
11334        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11335        let md = adf_to_markdown(&doc).unwrap();
11336        let round_tripped = markdown_to_adf(&md).unwrap();
11337        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11338        let mark_types: Vec<&str> = node
11339            .marks
11340            .as_ref()
11341            .unwrap()
11342            .iter()
11343            .map(|m| m.mark_type.as_str())
11344            .collect();
11345        assert_eq!(
11346            mark_types,
11347            vec!["underline", "strike", "link"],
11348            "mark order should be preserved, got: {mark_types:?}"
11349        );
11350    }
11351
11352    #[test]
11353    fn mark_ordering_underline_strong_em_link_preserved() {
11354        // Issue #491: verify four-mark combo [underline, strong, em, link] is preserved
11355        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11356          {"type":"text","text":"all the marks","marks":[
11357            {"type":"underline"},
11358            {"type":"strong"},
11359            {"type":"em"},
11360            {"type":"link","attrs":{"href":"https://example.com/page"}}
11361          ]}
11362        ]}]}"#;
11363        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11364        let md = adf_to_markdown(&doc).unwrap();
11365        let round_tripped = markdown_to_adf(&md).unwrap();
11366        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11367        let mark_types: Vec<&str> = node
11368            .marks
11369            .as_ref()
11370            .unwrap()
11371            .iter()
11372            .map(|m| m.mark_type.as_str())
11373            .collect();
11374        assert_eq!(
11375            mark_types,
11376            vec!["underline", "strong", "em", "link"],
11377            "mark order should be preserved, got: {mark_types:?}"
11378        );
11379    }
11380
11381    #[test]
11382    fn em_strong_round_trip() {
11383        // Issue #401: em mark dropped when combined with strong
11384        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11385          {"type":"text","text":"bold and italic","marks":[{"type":"strong"},{"type":"em"}]}
11386        ]}]}"#;
11387        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11388        let md = adf_to_markdown(&doc).unwrap();
11389        assert_eq!(md.trim(), "***bold and italic***");
11390        let round_tripped = markdown_to_adf(&md).unwrap();
11391        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11392        assert_eq!(node.text.as_deref(), Some("bold and italic"));
11393        let mark_types: Vec<&str> = node
11394            .marks
11395            .as_ref()
11396            .unwrap()
11397            .iter()
11398            .map(|m| m.mark_type.as_str())
11399            .collect();
11400        assert_eq!(
11401            mark_types,
11402            vec!["strong", "em"],
11403            "both strong and em marks should be preserved, got: {mark_types:?}"
11404        );
11405    }
11406
11407    #[test]
11408    fn em_strong_round_trip_em_first() {
11409        // Issue #549: [em, strong] mark order must be preserved on round-trip.
11410        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11411          {"type":"text","text":"italic and bold","marks":[{"type":"em"},{"type":"strong"}]}
11412        ]}]}"#;
11413        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11414        let md = adf_to_markdown(&doc).unwrap();
11415        let round_tripped = markdown_to_adf(&md).unwrap();
11416        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11417        assert_eq!(node.text.as_deref(), Some("italic and bold"));
11418        let mark_types: Vec<&str> = node
11419            .marks
11420            .as_ref()
11421            .unwrap()
11422            .iter()
11423            .map(|m| m.mark_type.as_str())
11424            .collect();
11425        assert_eq!(
11426            mark_types,
11427            vec!["em", "strong"],
11428            "mark order [em, strong] should be preserved, got: {mark_types:?}"
11429        );
11430    }
11431
11432    /// Round-trips an inline text node with the given marks through ADF → JFM → ADF
11433    /// and asserts the resulting mark types match `expected`.
11434    fn assert_mark_order_round_trip(marks: Vec<AdfMark>, expected: &[&str]) {
11435        let doc = AdfDocument {
11436            version: 1,
11437            doc_type: "doc".to_string(),
11438            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11439                "text", marks,
11440            )])],
11441        };
11442        let md = adf_to_markdown(&doc).unwrap();
11443        let round_tripped = markdown_to_adf(&md).unwrap();
11444        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11445        let mark_types: Vec<&str> = node
11446            .marks
11447            .as_ref()
11448            .expect("round-tripped node should have marks")
11449            .iter()
11450            .map(|m| m.mark_type.as_str())
11451            .collect();
11452        assert_eq!(
11453            mark_types, expected,
11454            "marks should round-trip in order via {md:?}"
11455        );
11456    }
11457
11458    #[test]
11459    fn round_trip_em_strong_mark_order() {
11460        // Issue #549: em + strong in either order must round-trip.
11461        assert_mark_order_round_trip(vec![AdfMark::em(), AdfMark::strong()], &["em", "strong"]);
11462        assert_mark_order_round_trip(vec![AdfMark::strong(), AdfMark::em()], &["strong", "em"]);
11463    }
11464
11465    #[test]
11466    fn round_trip_strong_underline_mark_order() {
11467        // Issue #549: strong + underline in either order must round-trip.
11468        assert_mark_order_round_trip(
11469            vec![AdfMark::strong(), AdfMark::underline()],
11470            &["strong", "underline"],
11471        );
11472        assert_mark_order_round_trip(
11473            vec![AdfMark::underline(), AdfMark::strong()],
11474            &["underline", "strong"],
11475        );
11476    }
11477
11478    #[test]
11479    fn round_trip_em_underline_mark_order() {
11480        assert_mark_order_round_trip(
11481            vec![AdfMark::em(), AdfMark::underline()],
11482            &["em", "underline"],
11483        );
11484        assert_mark_order_round_trip(
11485            vec![AdfMark::underline(), AdfMark::em()],
11486            &["underline", "em"],
11487        );
11488    }
11489
11490    #[test]
11491    fn round_trip_strike_strong_em_permutations() {
11492        // Each permutation of {strike, strong, em} must round-trip the mark order
11493        // exactly, because the Atlassian ADF spec does not define a canonical mark
11494        // ordering and we preserve whatever ordering Jira delivered.
11495        assert_mark_order_round_trip(
11496            vec![AdfMark::strike(), AdfMark::strong(), AdfMark::em()],
11497            &["strike", "strong", "em"],
11498        );
11499        assert_mark_order_round_trip(
11500            vec![AdfMark::strike(), AdfMark::em(), AdfMark::strong()],
11501            &["strike", "em", "strong"],
11502        );
11503        assert_mark_order_round_trip(
11504            vec![AdfMark::strong(), AdfMark::strike(), AdfMark::em()],
11505            &["strong", "strike", "em"],
11506        );
11507        assert_mark_order_round_trip(
11508            vec![AdfMark::strong(), AdfMark::em(), AdfMark::strike()],
11509            &["strong", "em", "strike"],
11510        );
11511        assert_mark_order_round_trip(
11512            vec![AdfMark::em(), AdfMark::strike(), AdfMark::strong()],
11513            &["em", "strike", "strong"],
11514        );
11515        assert_mark_order_round_trip(
11516            vec![AdfMark::em(), AdfMark::strong(), AdfMark::strike()],
11517            &["em", "strong", "strike"],
11518        );
11519    }
11520
11521    #[test]
11522    fn round_trip_underline_nested_with_strong_em() {
11523        // Underline may sit outside, between, or inside strong/em — each position
11524        // must round-trip.
11525        assert_mark_order_round_trip(
11526            vec![AdfMark::underline(), AdfMark::strong(), AdfMark::em()],
11527            &["underline", "strong", "em"],
11528        );
11529        assert_mark_order_round_trip(
11530            vec![AdfMark::strong(), AdfMark::underline(), AdfMark::em()],
11531            &["strong", "underline", "em"],
11532        );
11533        assert_mark_order_round_trip(
11534            vec![AdfMark::strong(), AdfMark::em(), AdfMark::underline()],
11535            &["strong", "em", "underline"],
11536        );
11537    }
11538
11539    #[test]
11540    fn round_trip_span_attr_order_preserved() {
11541        // Issue #549: the `:span` directive always parses color/bg/subsup
11542        // attrs in a fixed order, so non-canonical orderings must be emitted
11543        // as nested :span wrappers rather than a single merged wrapper.
11544        assert_mark_order_round_trip(
11545            vec![
11546                AdfMark::background_color("#ffff00"),
11547                AdfMark::text_color("#ff0000"),
11548            ],
11549            &["backgroundColor", "textColor"],
11550        );
11551        assert_mark_order_round_trip(
11552            vec![AdfMark::subsup("sub"), AdfMark::text_color("#ff0000")],
11553            &["subsup", "textColor"],
11554        );
11555        assert_mark_order_round_trip(
11556            vec![
11557                AdfMark::text_color("#ff0000"),
11558                AdfMark::background_color("#ffff00"),
11559            ],
11560            &["textColor", "backgroundColor"],
11561        );
11562    }
11563
11564    #[test]
11565    fn round_trip_annotation_before_underline() {
11566        // Issue #549: the bracketed-span parser reads `underline` before any
11567        // annotation-ids, so `[annotation, underline]` must be emitted as
11568        // nested wrappers rather than one merged `[text]{underline annotation-id=X}`.
11569        assert_mark_order_round_trip(
11570            vec![
11571                AdfMark::annotation("ann-1", "inlineComment"),
11572                AdfMark::underline(),
11573            ],
11574            &["annotation", "underline"],
11575        );
11576        assert_mark_order_round_trip(
11577            vec![
11578                AdfMark::annotation("ann-1", "inlineComment"),
11579                AdfMark::underline(),
11580                AdfMark::annotation("ann-2", "inlineComment"),
11581            ],
11582            &["annotation", "underline", "annotation"],
11583        );
11584    }
11585
11586    #[test]
11587    fn round_trip_em_content_with_underscores() {
11588        // When em renders as `_..._` (to disambiguate from strong), any literal
11589        // underscores in the text must be escaped so they don't close the
11590        // emphasis span early.  Text like "foo_bar_baz" with [em, strong] must
11591        // survive round-trip with the underscores intact.
11592        let doc = AdfDocument {
11593            version: 1,
11594            doc_type: "doc".to_string(),
11595            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11596                "foo _bar_ baz",
11597                vec![AdfMark::em(), AdfMark::strong()],
11598            )])],
11599        };
11600        let md = adf_to_markdown(&doc).unwrap();
11601        let round_tripped = markdown_to_adf(&md).unwrap();
11602        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11603        assert_eq!(node.text.as_deref(), Some("foo _bar_ baz"));
11604        let mark_types: Vec<&str> = node
11605            .marks
11606            .as_ref()
11607            .unwrap()
11608            .iter()
11609            .map(|m| m.mark_type.as_str())
11610            .collect();
11611        assert_eq!(mark_types, vec!["em", "strong"]);
11612    }
11613
11614    #[test]
11615    fn round_trip_link_nested_with_formatting_marks() {
11616        // Link may sit at any position in the marks array relative to em,
11617        // strong, strike, and underline — each position must round-trip.
11618        assert_mark_order_round_trip(
11619            vec![
11620                AdfMark::link("https://example.com"),
11621                AdfMark::strong(),
11622                AdfMark::em(),
11623            ],
11624            &["link", "strong", "em"],
11625        );
11626        assert_mark_order_round_trip(
11627            vec![
11628                AdfMark::em(),
11629                AdfMark::strong(),
11630                AdfMark::link("https://example.com"),
11631            ],
11632            &["em", "strong", "link"],
11633        );
11634        assert_mark_order_round_trip(
11635            vec![
11636                AdfMark::underline(),
11637                AdfMark::link("https://example.com"),
11638                AdfMark::strong(),
11639            ],
11640            &["underline", "link", "strong"],
11641        );
11642    }
11643
11644    /// Builds an `AdfMark` with the given type and no attrs, bypassing the
11645    /// usual constructors so we can exercise the defensive branches in the
11646    /// render helpers (the constructors always populate `attrs`).
11647    fn bare_mark(mark_type: &str) -> AdfMark {
11648        AdfMark {
11649            mark_type: mark_type.to_string(),
11650            attrs: None,
11651        }
11652    }
11653
11654    #[test]
11655    fn collect_span_attr_handles_missing_attrs() {
11656        // `textColor`/`backgroundColor`/`subsup` marks without the expected
11657        // `color`/`type` attr must not emit a fragment (the `if let` falls
11658        // through without pushing).  This exercises the inner-None branches
11659        // that the typed-constructor tests otherwise skip.
11660        let mut attrs = Vec::new();
11661        collect_span_attr(&bare_mark("textColor"), &mut attrs);
11662        collect_span_attr(&bare_mark("backgroundColor"), &mut attrs);
11663        collect_span_attr(&bare_mark("subsup"), &mut attrs);
11664        collect_span_attr(&bare_mark("link"), &mut attrs);
11665        assert!(attrs.is_empty(), "got: {attrs:?}");
11666    }
11667
11668    #[test]
11669    fn collect_bracketed_attr_handles_missing_attrs() {
11670        // An annotation mark with no attrs map at all must silently produce
11671        // no fragments — this covers the outer `if let Some(ref a)` None arm.
11672        let mut attrs = Vec::new();
11673        collect_bracketed_attr(&bare_mark("annotation"), &mut attrs);
11674        collect_bracketed_attr(&bare_mark("strong"), &mut attrs);
11675        assert!(attrs.is_empty(), "got: {attrs:?}");
11676    }
11677
11678    #[test]
11679    fn collect_bracketed_attr_handles_annotation_without_id() {
11680        // An annotation mark with attrs present but missing `id` and
11681        // `annotationType` keys still emits nothing — exercises the inner
11682        // None branches of each `if let` in the annotation arm.
11683        let mark = AdfMark {
11684            mark_type: "annotation".to_string(),
11685            attrs: Some(serde_json::json!({})),
11686        };
11687        let mut attrs = Vec::new();
11688        collect_bracketed_attr(&mark, &mut attrs);
11689        assert!(attrs.is_empty(), "got: {attrs:?}");
11690    }
11691
11692    #[test]
11693    fn span_attr_order_rejects_unknown_types() {
11694        // `span_attr_order` must classify unknown mark types as the sentinel
11695        // value, and `span_run_is_canonical` must reject a run that contains
11696        // any such unknown type.
11697        assert_eq!(span_attr_order("textColor"), 0);
11698        assert_eq!(span_attr_order("backgroundColor"), 1);
11699        assert_eq!(span_attr_order("subsup"), 2);
11700        assert_eq!(span_attr_order("strong"), u8::MAX);
11701        assert!(!span_run_is_canonical(&[bare_mark("strong")]));
11702    }
11703
11704    #[test]
11705    fn bracketed_run_rejects_unknown_types() {
11706        // `bracketed_run_is_canonical` only accepts `underline` and
11707        // `annotation`; any other mark type in the run short-circuits to
11708        // `false` so the caller emits nested wrappers.
11709        assert!(bracketed_run_is_canonical(&[
11710            AdfMark::underline(),
11711            AdfMark::annotation("x", "inlineComment")
11712        ]));
11713        assert!(!bracketed_run_is_canonical(&[
11714            AdfMark::annotation("x", "inlineComment"),
11715            AdfMark::underline()
11716        ]));
11717        assert!(!bracketed_run_is_canonical(&[bare_mark("strong")]));
11718    }
11719
11720    #[test]
11721    fn render_marked_text_ignores_unknown_mark_types() {
11722        // Unknown mark types fall through `render_marked_text`'s `_ =>`
11723        // arm and are dropped; the rendered JFM must still produce the
11724        // underlying text (and round-trip back to an unmarked text node).
11725        let doc = AdfDocument {
11726            version: 1,
11727            doc_type: "doc".to_string(),
11728            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11729                "hello",
11730                vec![bare_mark("futureMark"), AdfMark::strong()],
11731            )])],
11732        };
11733        let md = adf_to_markdown(&doc).unwrap();
11734        assert_eq!(md.trim(), "**hello**");
11735        let rt = markdown_to_adf(&md).unwrap();
11736        let node = &rt.content[0].content.as_ref().unwrap()[0];
11737        assert_eq!(node.text.as_deref(), Some("hello"));
11738        let mark_types: Vec<&str> = node
11739            .marks
11740            .as_ref()
11741            .unwrap()
11742            .iter()
11743            .map(|m| m.mark_type.as_str())
11744            .collect();
11745        assert_eq!(mark_types, vec!["strong"]);
11746    }
11747
11748    #[test]
11749    fn triple_asterisk_parse_to_adf() {
11750        // Issue #401: ***text*** should parse as text with strong+em marks
11751        let md = "***bold and italic***\n";
11752        let doc = markdown_to_adf(md).unwrap();
11753        let node = &doc.content[0].content.as_ref().unwrap()[0];
11754        assert_eq!(node.text.as_deref(), Some("bold and italic"));
11755        let mark_types: Vec<&str> = node
11756            .marks
11757            .as_ref()
11758            .unwrap()
11759            .iter()
11760            .map(|m| m.mark_type.as_str())
11761            .collect();
11762        assert!(
11763            mark_types.contains(&"strong") && mark_types.contains(&"em"),
11764            "***text*** should produce both strong and em marks, got: {mark_types:?}"
11765        );
11766    }
11767
11768    #[test]
11769    fn triple_asterisk_with_surrounding_text() {
11770        // Issue #401: surrounding text should not be affected
11771        let md = "before ***bold italic*** after\n";
11772        let doc = markdown_to_adf(md).unwrap();
11773        let nodes = doc.content[0].content.as_ref().unwrap();
11774        // Should have: "before " (plain), "bold italic" (strong+em), " after" (plain)
11775        assert!(
11776            nodes.len() >= 3,
11777            "expected at least 3 nodes, got {}",
11778            nodes.len()
11779        );
11780        assert_eq!(nodes[0].text.as_deref(), Some("before "));
11781        assert_eq!(nodes[1].text.as_deref(), Some("bold italic"));
11782        let mark_types: Vec<&str> = nodes[1]
11783            .marks
11784            .as_ref()
11785            .unwrap()
11786            .iter()
11787            .map(|m| m.mark_type.as_str())
11788            .collect();
11789        assert!(
11790            mark_types.contains(&"strong") && mark_types.contains(&"em"),
11791            "middle node should have strong+em, got: {mark_types:?}"
11792        );
11793        assert_eq!(nodes[2].text.as_deref(), Some(" after"));
11794    }
11795
11796    #[test]
11797    fn annotation_mark_round_trip() {
11798        // Issue #364: annotation marks were silently dropped
11799        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11800          {"type":"text","text":"highlighted text","marks":[
11801            {"type":"annotation","attrs":{"id":"abc123","annotationType":"inlineComment"}}
11802          ]}
11803        ]}]}"#;
11804        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11805
11806        let md = adf_to_markdown(&doc).unwrap();
11807        assert!(
11808            md.contains("annotation-id="),
11809            "JFM should contain annotation-id, got: {md}"
11810        );
11811
11812        let round_tripped = markdown_to_adf(&md).unwrap();
11813        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11814        assert_eq!(text_node.text.as_deref(), Some("highlighted text"));
11815        let marks = text_node.marks.as_ref().expect("should have marks");
11816        let ann = marks
11817            .iter()
11818            .find(|m| m.mark_type == "annotation")
11819            .expect("should have annotation mark");
11820        let attrs = ann.attrs.as_ref().unwrap();
11821        assert_eq!(attrs["id"], "abc123");
11822        assert_eq!(attrs["annotationType"], "inlineComment");
11823    }
11824
11825    #[test]
11826    fn annotation_mark_with_bold() {
11827        // Annotation + bold should both survive round-trip
11828        let doc = AdfDocument {
11829            version: 1,
11830            doc_type: "doc".to_string(),
11831            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11832                "bold comment",
11833                vec![
11834                    AdfMark::strong(),
11835                    AdfMark::annotation("def456", "inlineComment"),
11836                ],
11837            )])],
11838        };
11839        let md = adf_to_markdown(&doc).unwrap();
11840        let round_tripped = markdown_to_adf(&md).unwrap();
11841        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11842        let marks = text_node.marks.as_ref().expect("should have marks");
11843        assert!(
11844            marks.iter().any(|m| m.mark_type == "strong"),
11845            "should have strong mark"
11846        );
11847        assert!(
11848            marks.iter().any(|m| m.mark_type == "annotation"),
11849            "should have annotation mark"
11850        );
11851    }
11852
11853    #[test]
11854    fn annotation_and_link_marks_both_preserved() {
11855        // Issue #390: text with both annotation and link marks loses link on round-trip
11856        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11857          {"type":"text","text":"HANGUL-8","marks":[
11858            {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"5ca7425e-34cd-48d3-b4eb-9873ac8b20e0"}},
11859            {"type":"link","attrs":{"href":"https://zd.atlassian.net/browse/HANG-8"}}
11860          ]}
11861        ]}]}"#;
11862        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11863        let md = adf_to_markdown(&doc).unwrap();
11864        // Should contain both annotation attrs and link syntax
11865        assert!(
11866            md.contains("annotation-id="),
11867            "JFM should contain annotation-id, got: {md}"
11868        );
11869        assert!(
11870            md.contains("](https://"),
11871            "JFM should contain link href, got: {md}"
11872        );
11873        let round_tripped = markdown_to_adf(&md).unwrap();
11874        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11875        let marks = text_node.marks.as_ref().expect("should have marks");
11876        assert!(
11877            marks.iter().any(|m| m.mark_type == "annotation"),
11878            "should have annotation mark, got: {:?}",
11879            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
11880        );
11881        assert!(
11882            marks.iter().any(|m| m.mark_type == "link"),
11883            "should have link mark, got: {:?}",
11884            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
11885        );
11886    }
11887
11888    #[test]
11889    fn annotation_and_code_marks_both_preserved() {
11890        // Issue #508: annotation mark dropped when combined with code mark
11891        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11892          {"type":"text","text":"some text with "},
11893          {"type":"text","text":"annotated code","marks":[
11894            {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"aabbccdd-1234-5678-abcd-000000000001"}},
11895            {"type":"code"}
11896          ]},
11897          {"type":"text","text":" remaining text"}
11898        ]}]}"#;
11899        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11900        let md = adf_to_markdown(&doc).unwrap();
11901        assert!(
11902            md.contains("annotation-id="),
11903            "JFM should contain annotation-id, got: {md}"
11904        );
11905        assert!(
11906            md.contains('`'),
11907            "JFM should contain backticks for code, got: {md}"
11908        );
11909
11910        let round_tripped = markdown_to_adf(&md).unwrap();
11911        let nodes = round_tripped.content[0].content.as_ref().unwrap();
11912        // Find the text node with "annotated code"
11913        let code_node = nodes
11914            .iter()
11915            .find(|n| n.text.as_deref() == Some("annotated code"))
11916            .expect("should have 'annotated code' text node");
11917        let marks = code_node.marks.as_ref().expect("should have marks");
11918        assert!(
11919            marks.iter().any(|m| m.mark_type == "annotation"),
11920            "should have annotation mark, got: {:?}",
11921            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
11922        );
11923        assert!(
11924            marks.iter().any(|m| m.mark_type == "code"),
11925            "should have code mark, got: {:?}",
11926            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
11927        );
11928        let ann = marks.iter().find(|m| m.mark_type == "annotation").unwrap();
11929        let attrs = ann.attrs.as_ref().unwrap();
11930        assert_eq!(attrs["id"], "aabbccdd-1234-5678-abcd-000000000001");
11931        assert_eq!(attrs["annotationType"], "inlineComment");
11932    }
11933
11934    #[test]
11935    fn annotation_and_code_and_link_marks_all_preserved() {
11936        // annotation + code + link should all survive round-trip
11937        let doc = AdfDocument {
11938            version: 1,
11939            doc_type: "doc".to_string(),
11940            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11941                "linked code",
11942                vec![
11943                    AdfMark::annotation("ann-001", "inlineComment"),
11944                    AdfMark::code(),
11945                    AdfMark::link("https://example.com"),
11946                ],
11947            )])],
11948        };
11949        let md = adf_to_markdown(&doc).unwrap();
11950        assert!(
11951            md.contains("annotation-id="),
11952            "JFM should contain annotation-id, got: {md}"
11953        );
11954        assert!(md.contains('`'), "JFM should contain backticks, got: {md}");
11955        assert!(
11956            md.contains("](https://example.com)"),
11957            "JFM should contain link, got: {md}"
11958        );
11959
11960        let round_tripped = markdown_to_adf(&md).unwrap();
11961        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11962        let marks = text_node.marks.as_ref().expect("should have marks");
11963        assert!(
11964            marks.iter().any(|m| m.mark_type == "annotation"),
11965            "should have annotation mark, got: {:?}",
11966            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
11967        );
11968        assert!(
11969            marks.iter().any(|m| m.mark_type == "code"),
11970            "should have code mark, got: {:?}",
11971            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
11972        );
11973        assert!(
11974            marks.iter().any(|m| m.mark_type == "link"),
11975            "should have link mark, got: {:?}",
11976            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
11977        );
11978    }
11979
11980    #[test]
11981    fn multiple_annotations_and_code_mark_preserved() {
11982        // Multiple annotation marks on a code node should all survive
11983        let doc = AdfDocument {
11984            version: 1,
11985            doc_type: "doc".to_string(),
11986            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11987                "doubly annotated",
11988                vec![
11989                    AdfMark::annotation("ann-aaa", "inlineComment"),
11990                    AdfMark::annotation("ann-bbb", "inlineComment"),
11991                    AdfMark::code(),
11992                ],
11993            )])],
11994        };
11995        let md = adf_to_markdown(&doc).unwrap();
11996        assert!(
11997            md.contains("ann-aaa"),
11998            "JFM should contain first annotation id, got: {md}"
11999        );
12000        assert!(
12001            md.contains("ann-bbb"),
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        let marks = text_node.marks.as_ref().expect("should have marks");
12008        let ann_marks: Vec<_> = marks
12009            .iter()
12010            .filter(|m| m.mark_type == "annotation")
12011            .collect();
12012        assert_eq!(
12013            ann_marks.len(),
12014            2,
12015            "should have 2 annotation marks, got: {}",
12016            ann_marks.len()
12017        );
12018        assert!(
12019            marks.iter().any(|m| m.mark_type == "code"),
12020            "should have code mark"
12021        );
12022    }
12023
12024    #[test]
12025    fn underline_and_link_marks_both_preserved() {
12026        // Underline + link should also coexist
12027        let doc = AdfDocument {
12028            version: 1,
12029            doc_type: "doc".to_string(),
12030            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12031                "click here",
12032                vec![AdfMark::underline(), AdfMark::link("https://example.com")],
12033            )])],
12034        };
12035        let md = adf_to_markdown(&doc).unwrap();
12036        assert!(md.contains("underline"), "should have underline attr: {md}");
12037        assert!(
12038            md.contains("](https://example.com)"),
12039            "should have link: {md}"
12040        );
12041        let round_tripped = markdown_to_adf(&md).unwrap();
12042        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12043        let marks = text_node.marks.as_ref().expect("should have marks");
12044        assert!(marks.iter().any(|m| m.mark_type == "underline"));
12045        assert!(marks.iter().any(|m| m.mark_type == "link"));
12046    }
12047
12048    #[test]
12049    fn annotation_link_and_bold_all_preserved() {
12050        // All three marks should coexist
12051        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12052          {"type":"text","text":"important","marks":[
12053            {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"abc"}},
12054            {"type":"link","attrs":{"href":"https://example.com"}},
12055            {"type":"strong"}
12056          ]}
12057        ]}]}"#;
12058        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12059        let md = adf_to_markdown(&doc).unwrap();
12060        let round_tripped = markdown_to_adf(&md).unwrap();
12061        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12062        let marks = text_node.marks.as_ref().expect("should have marks");
12063        assert!(
12064            marks.iter().any(|m| m.mark_type == "annotation"),
12065            "should have annotation"
12066        );
12067        assert!(
12068            marks.iter().any(|m| m.mark_type == "link"),
12069            "should have link"
12070        );
12071        assert!(
12072            marks.iter().any(|m| m.mark_type == "strong"),
12073            "should have strong"
12074        );
12075    }
12076
12077    #[test]
12078    fn multiple_annotation_marks_round_trip() {
12079        // Issue #439: multiple annotation marks on same text node — all but last dropped
12080        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12081          {"type":"text","text":"some annotated text","marks":[
12082            {"type":"annotation","attrs":{"id":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","annotationType":"inlineComment"}},
12083            {"type":"annotation","attrs":{"id":"ffffffff-1111-2222-3333-444444444444","annotationType":"inlineComment"}}
12084          ]}
12085        ]}]}"#;
12086        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12087
12088        let md = adf_to_markdown(&doc).unwrap();
12089        assert!(
12090            md.contains("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"),
12091            "JFM should contain first annotation id, got: {md}"
12092        );
12093        assert!(
12094            md.contains("ffffffff-1111-2222-3333-444444444444"),
12095            "JFM should contain second annotation id, got: {md}"
12096        );
12097
12098        let round_tripped = markdown_to_adf(&md).unwrap();
12099        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12100        assert_eq!(text_node.text.as_deref(), Some("some annotated text"));
12101        let marks = text_node.marks.as_ref().expect("should have marks");
12102        let annotations: Vec<_> = marks
12103            .iter()
12104            .filter(|m| m.mark_type == "annotation")
12105            .collect();
12106        assert_eq!(
12107            annotations.len(),
12108            2,
12109            "should have 2 annotation marks, got: {annotations:?}"
12110        );
12111        let ids: Vec<_> = annotations
12112            .iter()
12113            .map(|a| a.attrs.as_ref().unwrap()["id"].as_str().unwrap())
12114            .collect();
12115        assert!(ids.contains(&"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"));
12116        assert!(ids.contains(&"ffffffff-1111-2222-3333-444444444444"));
12117    }
12118
12119    #[test]
12120    fn three_annotation_marks_round_trip() {
12121        // Verify three overlapping annotations all survive
12122        let doc = AdfDocument {
12123            version: 1,
12124            doc_type: "doc".to_string(),
12125            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12126                "triple annotated",
12127                vec![
12128                    AdfMark::annotation("id-1", "inlineComment"),
12129                    AdfMark::annotation("id-2", "inlineComment"),
12130                    AdfMark::annotation("id-3", "inlineComment"),
12131                ],
12132            )])],
12133        };
12134        let md = adf_to_markdown(&doc).unwrap();
12135        let round_tripped = markdown_to_adf(&md).unwrap();
12136        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12137        let marks = text_node.marks.as_ref().expect("should have marks");
12138        let annotations: Vec<_> = marks
12139            .iter()
12140            .filter(|m| m.mark_type == "annotation")
12141            .collect();
12142        assert_eq!(
12143            annotations.len(),
12144            3,
12145            "should have 3 annotation marks, got: {annotations:?}"
12146        );
12147    }
12148
12149    #[test]
12150    fn multiple_annotations_with_bold_round_trip() {
12151        // Multiple annotations + bold should all survive
12152        let doc = AdfDocument {
12153            version: 1,
12154            doc_type: "doc".to_string(),
12155            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12156                "bold double annotated",
12157                vec![
12158                    AdfMark::strong(),
12159                    AdfMark::annotation("ann-a", "inlineComment"),
12160                    AdfMark::annotation("ann-b", "inlineComment"),
12161                ],
12162            )])],
12163        };
12164        let md = adf_to_markdown(&doc).unwrap();
12165        let round_tripped = markdown_to_adf(&md).unwrap();
12166        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12167        let marks = text_node.marks.as_ref().expect("should have marks");
12168        assert!(
12169            marks.iter().any(|m| m.mark_type == "strong"),
12170            "should have strong mark"
12171        );
12172        let annotations: Vec<_> = marks
12173            .iter()
12174            .filter(|m| m.mark_type == "annotation")
12175            .collect();
12176        assert_eq!(
12177            annotations.len(),
12178            2,
12179            "should have 2 annotation marks, got: {annotations:?}"
12180        );
12181    }
12182
12183    #[test]
12184    fn multiple_annotations_with_link_round_trip() {
12185        // Multiple annotations + link should all survive
12186        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12187          {"type":"text","text":"linked text","marks":[
12188            {"type":"annotation","attrs":{"id":"ann-x","annotationType":"inlineComment"}},
12189            {"type":"annotation","attrs":{"id":"ann-y","annotationType":"inlineComment"}},
12190            {"type":"link","attrs":{"href":"https://example.com"}}
12191          ]}
12192        ]}]}"#;
12193        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12194        let md = adf_to_markdown(&doc).unwrap();
12195        let round_tripped = markdown_to_adf(&md).unwrap();
12196        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12197        let marks = text_node.marks.as_ref().expect("should have marks");
12198        assert!(
12199            marks.iter().any(|m| m.mark_type == "link"),
12200            "should have link mark"
12201        );
12202        let annotations: Vec<_> = marks
12203            .iter()
12204            .filter(|m| m.mark_type == "annotation")
12205            .collect();
12206        assert_eq!(
12207            annotations.len(),
12208            2,
12209            "should have 2 annotation marks, got: {annotations:?}"
12210        );
12211    }
12212
12213    // ── Issue #471: annotation marks on non-text inline nodes ─────────
12214
12215    #[test]
12216    fn annotation_on_emoji_round_trip() {
12217        // Issue #471: annotation mark on emoji node should survive round-trip
12218        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12219          {"type":"emoji","attrs":{"id":"1f4dd","shortName":":memo:","text":"📝"},"marks":[
12220            {"type":"annotation","attrs":{"id":"ccddee11-2233-4455-aabb-ccddee112233","annotationType":"inlineComment"}}
12221          ]},
12222          {"type":"text","text":" annotated text","marks":[
12223            {"type":"annotation","attrs":{"id":"ccddee11-2233-4455-aabb-ccddee112233","annotationType":"inlineComment"}}
12224          ]}
12225        ]}]}"#;
12226        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12227        let md = adf_to_markdown(&doc).unwrap();
12228        assert!(
12229            md.contains("annotation-id="),
12230            "JFM should contain annotation-id for emoji, got: {md}"
12231        );
12232
12233        let round_tripped = markdown_to_adf(&md).unwrap();
12234        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12235
12236        // Emoji node should retain annotation mark
12237        let emoji_node = nodes.iter().find(|n| n.node_type == "emoji").unwrap();
12238        let emoji_marks = emoji_node.marks.as_ref().expect("emoji should have marks");
12239        assert!(
12240            emoji_marks.iter().any(|m| m.mark_type == "annotation"),
12241            "emoji should have annotation mark, got: {emoji_marks:?}"
12242        );
12243        let ann = emoji_marks
12244            .iter()
12245            .find(|m| m.mark_type == "annotation")
12246            .unwrap();
12247        assert_eq!(
12248            ann.attrs.as_ref().unwrap()["id"],
12249            "ccddee11-2233-4455-aabb-ccddee112233"
12250        );
12251
12252        // Text node should also retain annotation mark
12253        let text_node = nodes.iter().find(|n| n.node_type == "text").unwrap();
12254        let text_marks = text_node.marks.as_ref().expect("text should have marks");
12255        assert!(
12256            text_marks.iter().any(|m| m.mark_type == "annotation"),
12257            "text should have annotation mark"
12258        );
12259    }
12260
12261    #[test]
12262    fn annotation_on_status_round_trip() {
12263        let mut status = AdfNode::status("In Progress", "blue");
12264        status.marks = Some(vec![AdfMark::annotation("ann-status-1", "inlineComment")]);
12265
12266        let doc = AdfDocument {
12267            version: 1,
12268            doc_type: "doc".to_string(),
12269            content: vec![AdfNode::paragraph(vec![status])],
12270        };
12271        let md = adf_to_markdown(&doc).unwrap();
12272        assert!(
12273            md.contains("annotation-id="),
12274            "JFM should contain annotation-id for status, got: {md}"
12275        );
12276
12277        let round_tripped = markdown_to_adf(&md).unwrap();
12278        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12279        let status_node = nodes.iter().find(|n| n.node_type == "status").unwrap();
12280        let marks = status_node
12281            .marks
12282            .as_ref()
12283            .expect("status should have marks");
12284        assert!(
12285            marks.iter().any(|m| m.mark_type == "annotation"),
12286            "status should have annotation mark, got: {marks:?}"
12287        );
12288    }
12289
12290    #[test]
12291    fn annotation_on_date_round_trip() {
12292        let mut date = AdfNode::date("1704067200000");
12293        date.marks = Some(vec![AdfMark::annotation("ann-date-1", "inlineComment")]);
12294
12295        let doc = AdfDocument {
12296            version: 1,
12297            doc_type: "doc".to_string(),
12298            content: vec![AdfNode::paragraph(vec![date])],
12299        };
12300        let md = adf_to_markdown(&doc).unwrap();
12301        assert!(
12302            md.contains("annotation-id="),
12303            "JFM should contain annotation-id for date, got: {md}"
12304        );
12305
12306        let round_tripped = markdown_to_adf(&md).unwrap();
12307        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12308        let date_node = nodes.iter().find(|n| n.node_type == "date").unwrap();
12309        let marks = date_node.marks.as_ref().expect("date should have marks");
12310        assert!(
12311            marks.iter().any(|m| m.mark_type == "annotation"),
12312            "date should have annotation mark, got: {marks:?}"
12313        );
12314    }
12315
12316    #[test]
12317    fn annotation_on_mention_round_trip() {
12318        let mut mention = AdfNode::mention("user-123", "@Alice");
12319        mention.marks = Some(vec![AdfMark::annotation("ann-mention-1", "inlineComment")]);
12320
12321        let doc = AdfDocument {
12322            version: 1,
12323            doc_type: "doc".to_string(),
12324            content: vec![AdfNode::paragraph(vec![mention])],
12325        };
12326        let md = adf_to_markdown(&doc).unwrap();
12327        assert!(
12328            md.contains("annotation-id="),
12329            "JFM should contain annotation-id for mention, got: {md}"
12330        );
12331
12332        let round_tripped = markdown_to_adf(&md).unwrap();
12333        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12334        let mention_node = nodes.iter().find(|n| n.node_type == "mention").unwrap();
12335        let marks = mention_node
12336            .marks
12337            .as_ref()
12338            .expect("mention should have marks");
12339        assert!(
12340            marks.iter().any(|m| m.mark_type == "annotation"),
12341            "mention should have annotation mark, got: {marks:?}"
12342        );
12343    }
12344
12345    #[test]
12346    fn annotation_on_inline_card_round_trip() {
12347        let mut card = AdfNode::inline_card("https://example.com");
12348        card.marks = Some(vec![AdfMark::annotation("ann-card-1", "inlineComment")]);
12349
12350        let doc = AdfDocument {
12351            version: 1,
12352            doc_type: "doc".to_string(),
12353            content: vec![AdfNode::paragraph(vec![card])],
12354        };
12355        let md = adf_to_markdown(&doc).unwrap();
12356        assert!(
12357            md.contains("annotation-id="),
12358            "JFM should contain annotation-id for inlineCard, got: {md}"
12359        );
12360
12361        let round_tripped = markdown_to_adf(&md).unwrap();
12362        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12363        let card_node = nodes.iter().find(|n| n.node_type == "inlineCard").unwrap();
12364        let marks = card_node
12365            .marks
12366            .as_ref()
12367            .expect("inlineCard should have marks");
12368        assert!(
12369            marks.iter().any(|m| m.mark_type == "annotation"),
12370            "inlineCard should have annotation mark, got: {marks:?}"
12371        );
12372    }
12373
12374    #[test]
12375    fn annotation_on_placeholder_round_trip() {
12376        let mut placeholder = AdfNode::placeholder("Enter text here");
12377        placeholder.marks = Some(vec![AdfMark::annotation("ann-ph-1", "inlineComment")]);
12378
12379        let doc = AdfDocument {
12380            version: 1,
12381            doc_type: "doc".to_string(),
12382            content: vec![AdfNode::paragraph(vec![placeholder])],
12383        };
12384        let md = adf_to_markdown(&doc).unwrap();
12385        assert!(
12386            md.contains("annotation-id="),
12387            "JFM should contain annotation-id for placeholder, got: {md}"
12388        );
12389
12390        let round_tripped = markdown_to_adf(&md).unwrap();
12391        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12392        let ph_node = nodes.iter().find(|n| n.node_type == "placeholder").unwrap();
12393        let marks = ph_node
12394            .marks
12395            .as_ref()
12396            .expect("placeholder should have marks");
12397        assert!(
12398            marks.iter().any(|m| m.mark_type == "annotation"),
12399            "placeholder should have annotation mark, got: {marks:?}"
12400        );
12401    }
12402
12403    #[test]
12404    fn multiple_annotations_on_emoji_round_trip() {
12405        // Multiple annotation marks on a single emoji node
12406        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12407          {"type":"emoji","attrs":{"shortName":":fire:","text":"🔥"},"marks":[
12408            {"type":"annotation","attrs":{"id":"ann-1","annotationType":"inlineComment"}},
12409            {"type":"annotation","attrs":{"id":"ann-2","annotationType":"inlineComment"}}
12410          ]}
12411        ]}]}"#;
12412        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12413        let md = adf_to_markdown(&doc).unwrap();
12414
12415        let round_tripped = markdown_to_adf(&md).unwrap();
12416        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12417        let emoji_node = nodes.iter().find(|n| n.node_type == "emoji").unwrap();
12418        let marks = emoji_node.marks.as_ref().expect("emoji should have marks");
12419        let annotations: Vec<_> = marks
12420            .iter()
12421            .filter(|m| m.mark_type == "annotation")
12422            .collect();
12423        assert_eq!(
12424            annotations.len(),
12425            2,
12426            "emoji should have 2 annotation marks, got: {annotations:?}"
12427        );
12428    }
12429
12430    #[test]
12431    fn emoji_without_annotation_unchanged() {
12432        // Ensure emoji nodes without annotation marks are not affected
12433        let doc = AdfDocument {
12434            version: 1,
12435            doc_type: "doc".to_string(),
12436            content: vec![AdfNode::paragraph(vec![AdfNode::emoji(":fire:")])],
12437        };
12438        let md = adf_to_markdown(&doc).unwrap();
12439        // Should NOT have bracketed span wrapping
12440        assert!(
12441            !md.contains('['),
12442            "emoji without annotation should not be wrapped in brackets, got: {md}"
12443        );
12444        assert!(md.contains(":fire:"));
12445    }
12446
12447    // ── Inline directive tests (Tier 4) ───────────────────────────────
12448
12449    #[test]
12450    fn status_directive() {
12451        let doc = markdown_to_adf("The ticket is :status[In Progress]{color=blue}.").unwrap();
12452        let content = doc.content[0].content.as_ref().unwrap();
12453        assert_eq!(content[1].node_type, "status");
12454        assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "In Progress");
12455        assert_eq!(content[1].attrs.as_ref().unwrap()["color"], "blue");
12456    }
12457
12458    #[test]
12459    fn adf_status_to_markdown() {
12460        let doc = AdfDocument {
12461            version: 1,
12462            doc_type: "doc".to_string(),
12463            content: vec![AdfNode::paragraph(vec![AdfNode::status("Done", "green")])],
12464        };
12465        let md = adf_to_markdown(&doc).unwrap();
12466        assert!(md.contains(":status[Done]{color=green}"));
12467    }
12468
12469    #[test]
12470    fn round_trip_status() {
12471        let md = "The ticket is :status[In Progress]{color=blue}.\n";
12472        let doc = markdown_to_adf(md).unwrap();
12473        let result = adf_to_markdown(&doc).unwrap();
12474        assert!(result.contains(":status[In Progress]{color=blue}"));
12475    }
12476
12477    #[test]
12478    fn status_with_style_and_localid_roundtrips() {
12479        let adf = AdfDocument {
12480            version: 1,
12481            doc_type: "doc".to_string(),
12482            content: vec![AdfNode::paragraph(vec![{
12483                let mut node = AdfNode::status("open", "green");
12484                node.attrs.as_mut().unwrap()["style"] =
12485                    serde_json::Value::String("bold".to_string());
12486                node.attrs.as_mut().unwrap()["localId"] =
12487                    serde_json::Value::String("d2205ca5-84b9-4950-a730-bfe550fc146b".to_string());
12488                node
12489            }])],
12490        };
12491
12492        let md = adf_to_markdown(&adf).unwrap();
12493        assert!(
12494            md.contains("style=bold"),
12495            "Markdown should contain style attr: {md}"
12496        );
12497        assert!(
12498            md.contains("localId=d2205ca5"),
12499            "Markdown should contain localId attr: {md}"
12500        );
12501
12502        let rt = markdown_to_adf(&md).unwrap();
12503        let status = &rt.content[0].content.as_ref().unwrap()[0];
12504        let attrs = status.attrs.as_ref().unwrap();
12505        assert_eq!(attrs["text"], "open");
12506        assert_eq!(attrs["color"], "green");
12507        assert_eq!(attrs["style"], "bold");
12508        assert_eq!(
12509            attrs["localId"], "d2205ca5-84b9-4950-a730-bfe550fc146b",
12510            "localId should be preserved, got: {}",
12511            attrs["localId"]
12512        );
12513    }
12514
12515    #[test]
12516    fn status_without_style_still_works() {
12517        let md = ":status[Done]{color=green}\n";
12518        let doc = markdown_to_adf(md).unwrap();
12519        let status = &doc.content[0].content.as_ref().unwrap()[0];
12520        let attrs = status.attrs.as_ref().unwrap();
12521        assert_eq!(attrs["text"], "Done");
12522        assert_eq!(attrs["color"], "green");
12523        // No style attr — should not be present
12524        assert!(
12525            attrs.get("style").is_none() || attrs["style"].is_null(),
12526            "style should not be set when not provided"
12527        );
12528    }
12529
12530    #[test]
12531    fn strip_local_ids_removes_localid_from_status() {
12532        let adf = AdfDocument {
12533            version: 1,
12534            doc_type: "doc".to_string(),
12535            content: vec![AdfNode::paragraph(vec![{
12536                let mut node = AdfNode::status("open", "green");
12537                node.attrs.as_mut().unwrap()["localId"] =
12538                    serde_json::Value::String("real-uuid-here".to_string());
12539                node
12540            }])],
12541        };
12542        let opts = RenderOptions {
12543            strip_local_ids: true,
12544        };
12545        let md = adf_to_markdown_with_options(&adf, &opts).unwrap();
12546        assert!(
12547            !md.contains("localId"),
12548            "localId should be stripped, got: {md}"
12549        );
12550        assert!(md.contains("color=green"), "color should be preserved");
12551    }
12552
12553    #[test]
12554    fn strip_local_ids_removes_localid_from_table() {
12555        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"}]}]}]}]}]}"#;
12556        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12557        let opts = RenderOptions {
12558            strip_local_ids: true,
12559        };
12560        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12561        assert!(
12562            !md.contains("localId"),
12563            "localId should be stripped from table, got: {md}"
12564        );
12565        assert!(md.contains("layout=default"), "layout should be preserved");
12566    }
12567
12568    #[test]
12569    fn default_options_preserve_localid() {
12570        let adf = AdfDocument {
12571            version: 1,
12572            doc_type: "doc".to_string(),
12573            content: vec![AdfNode::paragraph(vec![{
12574                let mut node = AdfNode::status("open", "green");
12575                node.attrs.as_mut().unwrap()["localId"] =
12576                    serde_json::Value::String("real-uuid-here".to_string());
12577                node
12578            }])],
12579        };
12580        let md = adf_to_markdown(&adf).unwrap();
12581        assert!(
12582            md.contains("localId=real-uuid-here"),
12583            "Default should preserve localId, got: {md}"
12584        );
12585    }
12586
12587    #[test]
12588    fn mention_localid_roundtrip() {
12589        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"mention","attrs":{"id":"user123","text":"@Alice","localId":"m-001"}}]}]}"#;
12590        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12591        let md = adf_to_markdown(&doc).unwrap();
12592        assert!(
12593            md.contains("localId=m-001"),
12594            "mention should have localId in md: {md}"
12595        );
12596        let rt = markdown_to_adf(&md).unwrap();
12597        let mention = &rt.content[0].content.as_ref().unwrap()[0];
12598        assert_eq!(mention.attrs.as_ref().unwrap()["localId"], "m-001");
12599    }
12600
12601    #[test]
12602    fn date_localid_roundtrip() {
12603        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
12604        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12605        let md = adf_to_markdown(&doc).unwrap();
12606        assert!(
12607            md.contains("localId=d-001"),
12608            "date should have localId in md: {md}"
12609        );
12610        let rt = markdown_to_adf(&md).unwrap();
12611        let date = &rt.content[0].content.as_ref().unwrap()[0];
12612        assert_eq!(date.attrs.as_ref().unwrap()["localId"], "d-001");
12613    }
12614
12615    #[test]
12616    fn emoji_localid_roundtrip() {
12617        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"emoji","attrs":{"shortName":":smile:","localId":"e-001"}}]}]}"#;
12618        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12619        let md = adf_to_markdown(&doc).unwrap();
12620        assert!(
12621            md.contains("localId=e-001"),
12622            "emoji should have localId in md: {md}"
12623        );
12624        let rt = markdown_to_adf(&md).unwrap();
12625        let emoji = &rt.content[0].content.as_ref().unwrap()[0];
12626        assert_eq!(emoji.attrs.as_ref().unwrap()["localId"], "e-001");
12627    }
12628
12629    #[test]
12630    fn inline_card_localid_roundtrip() {
12631        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"inlineCard","attrs":{"url":"https://example.com","localId":"c-001"}}]}]}"#;
12632        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12633        let md = adf_to_markdown(&doc).unwrap();
12634        assert!(
12635            md.contains("localId=c-001"),
12636            "inlineCard should have localId in md: {md}"
12637        );
12638        let rt = markdown_to_adf(&md).unwrap();
12639        let card = &rt.content[0].content.as_ref().unwrap()[0];
12640        assert_eq!(card.attrs.as_ref().unwrap()["localId"], "c-001");
12641    }
12642
12643    #[test]
12644    fn strip_local_ids_removes_from_mention() {
12645        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"mention","attrs":{"id":"user123","text":"@Alice","localId":"m-001"}}]}]}"#;
12646        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12647        let opts = RenderOptions {
12648            strip_local_ids: true,
12649        };
12650        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12651        assert!(
12652            !md.contains("localId"),
12653            "localId should be stripped from mention: {md}"
12654        );
12655        assert!(md.contains("id=user123"), "other attrs should be preserved");
12656    }
12657
12658    #[test]
12659    fn strip_local_ids_removes_from_date() {
12660        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
12661        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12662        let opts = RenderOptions {
12663            strip_local_ids: true,
12664        };
12665        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12666        assert!(
12667            !md.contains("localId"),
12668            "localId should be stripped from date: {md}"
12669        );
12670    }
12671
12672    #[test]
12673    fn strip_local_ids_removes_from_block_attrs() {
12674        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"p-001"},"content":[{"type":"text","text":"hello"}]}]}"#;
12675        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12676        let opts = RenderOptions {
12677            strip_local_ids: true,
12678        };
12679        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12680        assert!(
12681            !md.contains("localId"),
12682            "localId should be stripped from block attrs: {md}"
12683        );
12684    }
12685
12686    #[test]
12687    fn table_cell_localid_roundtrip() {
12688        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"}]}]}]}]}]}"#;
12689        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12690        let md = adf_to_markdown(&doc).unwrap();
12691        assert!(
12692            md.contains("localId=tc-001"),
12693            "tableCell should have localId in md: {md}"
12694        );
12695        let rt = markdown_to_adf(&md).unwrap();
12696        let cell = &rt.content[0].content.as_ref().unwrap()[0]
12697            .content
12698            .as_ref()
12699            .unwrap()[0];
12700        assert_eq!(
12701            cell.attrs.as_ref().unwrap()["localId"],
12702            "tc-001",
12703            "tableCell localId should round-trip"
12704        );
12705    }
12706
12707    #[test]
12708    fn table_cell_border_mark_roundtrip() {
12709        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"}]}]}]}]}]}"##;
12710        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12711        let md = adf_to_markdown(&doc).unwrap();
12712        assert!(
12713            md.contains("border-color=#ff000033"),
12714            "tableCell should have border-color in md: {md}"
12715        );
12716        assert!(
12717            md.contains("border-size=2"),
12718            "tableCell should have border-size in md: {md}"
12719        );
12720        let rt = markdown_to_adf(&md).unwrap();
12721        let cell = &rt.content[0].content.as_ref().unwrap()[0]
12722            .content
12723            .as_ref()
12724            .unwrap()[0];
12725        let marks = cell.marks.as_ref().expect("tableCell should have marks");
12726        assert_eq!(marks.len(), 1);
12727        assert_eq!(marks[0].mark_type, "border");
12728        let attrs = marks[0].attrs.as_ref().unwrap();
12729        assert_eq!(attrs["color"], "#ff000033");
12730        assert_eq!(attrs["size"], 2);
12731    }
12732
12733    #[test]
12734    fn table_header_border_mark_roundtrip() {
12735        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"}]}]}]}]}]}"##;
12736        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12737        let md = adf_to_markdown(&doc).unwrap();
12738        assert!(md.contains("border-color=#0000ff"), "md: {md}");
12739        assert!(md.contains("border-size=3"), "md: {md}");
12740        let rt = markdown_to_adf(&md).unwrap();
12741        let cell = &rt.content[0].content.as_ref().unwrap()[0]
12742            .content
12743            .as_ref()
12744            .unwrap()[0];
12745        assert_eq!(cell.node_type, "tableHeader");
12746        let marks = cell.marks.as_ref().expect("tableHeader should have marks");
12747        assert_eq!(marks[0].mark_type, "border");
12748        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#0000ff");
12749        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
12750    }
12751
12752    #[test]
12753    fn table_cell_border_mark_with_attrs_roundtrip() {
12754        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"}]}]}]}]}]}"##;
12755        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12756        let md = adf_to_markdown(&doc).unwrap();
12757        assert!(md.contains("bg=#e6fcff"), "md: {md}");
12758        assert!(md.contains("colspan=2"), "md: {md}");
12759        assert!(md.contains("border-color=#ff000033"), "md: {md}");
12760        let rt = markdown_to_adf(&md).unwrap();
12761        let cell = &rt.content[0].content.as_ref().unwrap()[0]
12762            .content
12763            .as_ref()
12764            .unwrap()[0];
12765        assert_eq!(cell.attrs.as_ref().unwrap()["background"], "#e6fcff");
12766        assert_eq!(cell.attrs.as_ref().unwrap()["colspan"], 2);
12767        let marks = cell.marks.as_ref().expect("should have marks");
12768        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff000033");
12769    }
12770
12771    #[test]
12772    fn table_cell_no_border_mark_unchanged() {
12773        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"}]}]}]}]}]}"#;
12774        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12775        let md = adf_to_markdown(&doc).unwrap();
12776        assert!(
12777            !md.contains("border-color"),
12778            "no border attrs expected: {md}"
12779        );
12780        let rt = markdown_to_adf(&md).unwrap();
12781        let cell = &rt.content[0].content.as_ref().unwrap()[0]
12782            .content
12783            .as_ref()
12784            .unwrap()[0];
12785        assert!(cell.marks.is_none(), "no marks expected on plain cell");
12786    }
12787
12788    #[test]
12789    fn table_cell_border_size_only_defaults_color() {
12790        // border-size without border-color should still produce a border mark
12791        // with the default color
12792        let md = "::::table\n:::tr\n:::td{border-size=3}\ncell\n:::\n:::\n::::\n";
12793        let doc = markdown_to_adf(md).unwrap();
12794        let cell = &doc.content[0].content.as_ref().unwrap()[0]
12795            .content
12796            .as_ref()
12797            .unwrap()[0];
12798        let marks = cell.marks.as_ref().expect("should have border mark");
12799        assert_eq!(marks[0].mark_type, "border");
12800        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#000000");
12801        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
12802    }
12803
12804    #[test]
12805    fn table_cell_border_color_only_defaults_size() {
12806        // border-color without border-size should default size to 1
12807        let md = "::::table\n:::tr\n:::td{border-color=#ff0000}\ncell\n:::\n:::\n::::\n";
12808        let doc = markdown_to_adf(md).unwrap();
12809        let cell = &doc.content[0].content.as_ref().unwrap()[0]
12810            .content
12811            .as_ref()
12812            .unwrap()[0];
12813        let marks = cell.marks.as_ref().expect("should have border mark");
12814        assert_eq!(marks[0].mark_type, "border");
12815        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff0000");
12816        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 1);
12817    }
12818
12819    #[test]
12820    fn media_file_border_mark_roundtrip() {
12821        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}}]}]}]}"##;
12822        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12823        let md = adf_to_markdown(&doc).unwrap();
12824        assert!(
12825            md.contains("border-color=#091e4224"),
12826            "media should have border-color in md: {md}"
12827        );
12828        assert!(
12829            md.contains("border-size=2"),
12830            "media should have border-size in md: {md}"
12831        );
12832        let rt = markdown_to_adf(&md).unwrap();
12833        let media_single = &rt.content[0];
12834        let media = &media_single.content.as_ref().unwrap()[0];
12835        assert_eq!(media.node_type, "media");
12836        let marks = media.marks.as_ref().expect("media should have marks");
12837        assert_eq!(marks.len(), 1);
12838        assert_eq!(marks[0].mark_type, "border");
12839        let attrs = marks[0].attrs.as_ref().unwrap();
12840        assert_eq!(attrs["color"], "#091e4224");
12841        assert_eq!(attrs["size"], 2);
12842    }
12843
12844    #[test]
12845    fn media_external_border_mark_roundtrip() {
12846        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}}]}]}]}"##;
12847        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12848        let md = adf_to_markdown(&doc).unwrap();
12849        assert!(
12850            md.contains("border-color=#ff0000"),
12851            "external media should have border-color in md: {md}"
12852        );
12853        assert!(
12854            md.contains("border-size=3"),
12855            "external media should have border-size in md: {md}"
12856        );
12857        let rt = markdown_to_adf(&md).unwrap();
12858        let media = &rt.content[0].content.as_ref().unwrap()[0];
12859        let marks = media.marks.as_ref().expect("media should have marks");
12860        assert_eq!(marks[0].mark_type, "border");
12861        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff0000");
12862        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
12863    }
12864
12865    #[test]
12866    fn media_file_no_border_mark_unchanged() {
12867        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}}]}]}"#;
12868        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12869        let md = adf_to_markdown(&doc).unwrap();
12870        assert!(
12871            !md.contains("border-color"),
12872            "no border attrs expected: {md}"
12873        );
12874        let rt = markdown_to_adf(&md).unwrap();
12875        let media = &rt.content[0].content.as_ref().unwrap()[0];
12876        assert!(media.marks.is_none(), "no marks expected on plain media");
12877    }
12878
12879    #[test]
12880    fn media_border_size_only_defaults_color() {
12881        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}}]}]}]}"#;
12882        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12883        let md = adf_to_markdown(&doc).unwrap();
12884        assert!(md.contains("border-size=4"), "md: {md}");
12885        let rt = markdown_to_adf(&md).unwrap();
12886        let media = &rt.content[0].content.as_ref().unwrap()[0];
12887        let marks = media.marks.as_ref().expect("should have border mark");
12888        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#000000");
12889        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 4);
12890    }
12891
12892    #[test]
12893    fn media_border_color_only_defaults_size() {
12894        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"}}]}]}]}"##;
12895        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12896        let md = adf_to_markdown(&doc).unwrap();
12897        assert!(md.contains("border-color=#00ff00"), "md: {md}");
12898        let rt = markdown_to_adf(&md).unwrap();
12899        let media = &rt.content[0].content.as_ref().unwrap()[0];
12900        let marks = media.marks.as_ref().expect("should have border mark");
12901        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#00ff00");
12902        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 1);
12903    }
12904
12905    #[test]
12906    fn media_border_with_other_attrs_roundtrip() {
12907        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}}]}]}]}"##;
12908        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12909        let md = adf_to_markdown(&doc).unwrap();
12910        assert!(md.contains("layout=wide"), "md: {md}");
12911        assert!(md.contains("mediaWidth=600"), "md: {md}");
12912        assert!(md.contains("border-color=#091e4224"), "md: {md}");
12913        assert!(md.contains("border-size=2"), "md: {md}");
12914        let rt = markdown_to_adf(&md).unwrap();
12915        let ms = &rt.content[0];
12916        assert_eq!(ms.attrs.as_ref().unwrap()["layout"], "wide");
12917        let media = &ms.content.as_ref().unwrap()[0];
12918        let marks = media.marks.as_ref().expect("should have marks");
12919        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#091e4224");
12920        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 2);
12921    }
12922
12923    #[test]
12924    fn table_row_localid_roundtrip() {
12925        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"}]}]}]}]}]}"#;
12926        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12927        let md = adf_to_markdown(&doc).unwrap();
12928        assert!(
12929            md.contains("localId=tr-001"),
12930            "tableRow should have localId in md: {md}"
12931        );
12932        let rt = markdown_to_adf(&md).unwrap();
12933        let row = &rt.content[0].content.as_ref().unwrap()[0];
12934        assert_eq!(
12935            row.attrs.as_ref().unwrap()["localId"],
12936            "tr-001",
12937            "tableRow localId should round-trip"
12938        );
12939    }
12940
12941    #[test]
12942    fn list_item_localid_roundtrip() {
12943        // listItem localId is emitted as trailing inline attrs and parsed back
12944        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"}]}]}]}]}"#;
12945        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12946        let md = adf_to_markdown(&doc).unwrap();
12947        assert!(
12948            md.contains("localId=li-001"),
12949            "listItem should have localId in md: {md}"
12950        );
12951        // Verify localId is on the listItem, NOT promoted to bulletList
12952        let rt = markdown_to_adf(&md).unwrap();
12953        let list = &rt.content[0];
12954        assert!(
12955            list.attrs.is_none() || list.attrs.as_ref().unwrap().get("localId").is_none(),
12956            "bulletList should NOT have localId: {:?}",
12957            list.attrs
12958        );
12959        let item = &list.content.as_ref().unwrap()[0];
12960        assert_eq!(
12961            item.attrs.as_ref().unwrap()["localId"],
12962            "li-001",
12963            "listItem should have localId=li-001"
12964        );
12965    }
12966
12967    #[test]
12968    fn list_item_localid_not_promoted_to_parent() {
12969        // Verify localId stays on listItem and doesn't leak to parent list
12970        let md = "- item {localId=li-002}\n";
12971        let doc = markdown_to_adf(md).unwrap();
12972        let list = &doc.content[0];
12973        assert!(
12974            list.attrs.is_none(),
12975            "bulletList should have no attrs: {:?}",
12976            list.attrs
12977        );
12978        let item = &list.content.as_ref().unwrap()[0];
12979        assert_eq!(item.attrs.as_ref().unwrap()["localId"], "li-002");
12980    }
12981
12982    #[test]
12983    fn ordered_list_item_localid_roundtrip() {
12984        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"}]}]}]}]}"#;
12985        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12986        let md = adf_to_markdown(&doc).unwrap();
12987        assert!(md.contains("localId=oli-001"), "md: {md}");
12988        let rt = markdown_to_adf(&md).unwrap();
12989        let item = &rt.content[0].content.as_ref().unwrap()[0];
12990        assert_eq!(item.attrs.as_ref().unwrap()["localId"], "oli-001");
12991    }
12992
12993    #[test]
12994    fn task_item_localid_roundtrip() {
12995        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"}]}]}]}]}"#;
12996        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12997        let md = adf_to_markdown(&doc).unwrap();
12998        assert!(md.contains("localId=ti-001"), "md: {md}");
12999        let rt = markdown_to_adf(&md).unwrap();
13000        let item = &rt.content[0].content.as_ref().unwrap()[0];
13001        assert_eq!(item.attrs.as_ref().unwrap()["localId"], "ti-001");
13002    }
13003
13004    /// Issue #447: taskList with empty-string localId and taskItems with
13005    /// short numeric localIds must survive a full round-trip.
13006    #[test]
13007    fn task_list_short_localid_roundtrip() {
13008        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"}]}]}]}"#;
13009        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13010        let md = adf_to_markdown(&doc).unwrap();
13011        // Both taskItem localIds should appear in the markdown
13012        assert!(md.contains("localId=42"), "localId=42 missing: {md}");
13013        assert!(md.contains("localId=99"), "localId=99 missing: {md}");
13014        // Empty-string localId should NOT appear as {localId=}
13015        assert!(
13016            !md.contains("localId=}"),
13017            "empty localId should not be emitted: {md}"
13018        );
13019        let rt = markdown_to_adf(&md).unwrap();
13020        let task_list = &rt.content[0];
13021        assert_eq!(task_list.node_type, "taskList");
13022        // No spurious extra nodes from {localId=}
13023        assert_eq!(rt.content.len(), 1, "should be exactly one top-level node");
13024        let items = task_list.content.as_ref().unwrap();
13025        assert_eq!(items.len(), 2);
13026        // First taskItem: localId=42, state=TODO, no content
13027        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13028        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
13029        assert!(
13030            items[0].content.is_none(),
13031            "empty taskItem should have no content: {:?}",
13032            items[0].content
13033        );
13034        // Second taskItem: localId=99, state=DONE, content with text
13035        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "99");
13036        assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
13037        let content = items[1].content.as_ref().unwrap();
13038        assert_eq!(content.len(), 1);
13039        assert_eq!(content[0].text.as_deref(), Some("done task"));
13040    }
13041
13042    /// Issue #507: numeric localId on taskItem with hardBreak must survive
13043    /// round-trip — the {localId=…} suffix lands on the continuation line
13044    /// and must still be extracted by the parser.
13045    #[test]
13046    fn task_item_numeric_localid_with_hardbreak_roundtrip() {
13047        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!!)"}]}]}]}]}"#;
13048        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13049        let md = adf_to_markdown(&doc).unwrap();
13050        // localId must appear in the markdown output
13051        assert!(md.contains("localId=42"), "localId=42 missing: {md}");
13052        // Round-trip back to ADF
13053        let rt = markdown_to_adf(&md).unwrap();
13054        assert_eq!(rt.content.len(), 1, "exactly one top-level node");
13055        let task_list = &rt.content[0];
13056        assert_eq!(task_list.node_type, "taskList");
13057        let items = task_list.content.as_ref().unwrap();
13058        assert_eq!(items.len(), 1);
13059        // localId preserved
13060        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13061        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DONE");
13062        // Content structure preserved: paragraph with link + hardBreak + text
13063        let para = &items[0].content.as_ref().unwrap()[0];
13064        assert_eq!(para.node_type, "paragraph");
13065        let inlines = para.content.as_ref().unwrap();
13066        assert_eq!(inlines[0].node_type, "text");
13067        assert_eq!(
13068            inlines[0].text.as_deref(),
13069            Some("Engineering Onboarding Link")
13070        );
13071        assert_eq!(inlines[1].node_type, "hardBreak");
13072        assert_eq!(inlines[2].node_type, "text");
13073        assert_eq!(
13074            inlines[2].text.as_deref(),
13075            Some("(This has links to all the various useful tools!!)")
13076        );
13077        // The {localId=…} must not appear as literal text in the ADF output
13078        let rt_json = serde_json::to_string(&rt).unwrap();
13079        assert!(
13080            !rt_json.contains("{localId="),
13081            "localId attr syntax should not leak into ADF text: {rt_json}"
13082        );
13083    }
13084
13085    /// Issue #507: multiple taskItems with hardBreaks and numeric localIds.
13086    #[test]
13087    fn task_item_multiple_hardbreak_localids_roundtrip() {
13088        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"}]}]}]}]}"#;
13089        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13090        let md = adf_to_markdown(&doc).unwrap();
13091        assert!(md.contains("localId=42"), "localId=42 missing: {md}");
13092        assert!(md.contains("localId=67"), "localId=67 missing: {md}");
13093        let rt = markdown_to_adf(&md).unwrap();
13094        let items = rt.content[0].content.as_ref().unwrap();
13095        assert_eq!(items.len(), 2);
13096        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13097        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "67");
13098        // Verify hardBreak content structure for both items
13099        for item in items {
13100            let para = &item.content.as_ref().unwrap()[0];
13101            assert_eq!(para.node_type, "paragraph");
13102            let inlines = para.content.as_ref().unwrap();
13103            assert_eq!(inlines[1].node_type, "hardBreak");
13104        }
13105    }
13106
13107    /// Issue #521: sibling taskItems with numeric localIds and hardBreak —
13108    /// unwrapped inline content.  The hardBreak continuation line must be
13109    /// indented so it stays within the list item, and both localIds must
13110    /// survive the round-trip.
13111    #[test]
13112    fn task_item_sibling_localid_hardbreak_unwrapped_roundtrip() {
13113        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"}]}]}]}"#;
13114        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13115        let md = adf_to_markdown(&doc).unwrap();
13116        // Continuation line must be indented
13117        assert!(
13118            md.contains("  (parenthetical"),
13119            "continuation line should be 2-space indented: {md}"
13120        );
13121        assert!(md.contains("localId=42"), "localId=42 missing: {md}");
13122        assert!(md.contains("localId=69"), "localId=69 missing: {md}");
13123        let rt = markdown_to_adf(&md).unwrap();
13124        // Must remain a single taskList with 2 items
13125        assert_eq!(
13126            rt.content.len(),
13127            1,
13128            "should be one taskList: {:#?}",
13129            rt.content
13130        );
13131        assert_eq!(rt.content[0].node_type, "taskList");
13132        let items = rt.content[0].content.as_ref().unwrap();
13133        assert_eq!(items.len(), 2, "should have 2 taskItems");
13134        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13135        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "69");
13136        // Verify first item has hardBreak
13137        let first_content = items[0].content.as_ref().unwrap();
13138        assert!(
13139            first_content.iter().any(|n| n.node_type == "hardBreak"),
13140            "first item should contain hardBreak"
13141        );
13142        // Verify second item content
13143        let second_content = items[1].content.as_ref().unwrap();
13144        assert_eq!(second_content[0].node_type, "text");
13145        assert_eq!(
13146            second_content[0].text.as_deref().unwrap(),
13147            "second task item"
13148        );
13149    }
13150
13151    /// Issue #521: sibling taskItems with paragraph-wrapped content and
13152    /// hardBreak — localIds must not be swapped or lost.
13153    #[test]
13154    fn task_item_sibling_localid_hardbreak_paragraph_roundtrip() {
13155        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"}]}]}]}]}"#;
13156        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13157        let md = adf_to_markdown(&doc).unwrap();
13158        let rt = markdown_to_adf(&md).unwrap();
13159        assert_eq!(
13160            rt.content.len(),
13161            1,
13162            "should be one taskList: {:#?}",
13163            rt.content
13164        );
13165        let items = rt.content[0].content.as_ref().unwrap();
13166        assert_eq!(items.len(), 2);
13167        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13168        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "69");
13169    }
13170
13171    /// Issue #521: three sibling taskItems — the middle one has a hardBreak.
13172    /// Ensures localIds don't leak between adjacent items.
13173    #[test]
13174    fn task_item_three_siblings_middle_hardbreak_roundtrip() {
13175        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"}]}]}]}"#;
13176        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13177        let md = adf_to_markdown(&doc).unwrap();
13178        let rt = markdown_to_adf(&md).unwrap();
13179        assert_eq!(rt.content.len(), 1);
13180        let items = rt.content[0].content.as_ref().unwrap();
13181        assert_eq!(items.len(), 3);
13182        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "10");
13183        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "20");
13184        assert_eq!(items[2].attrs.as_ref().unwrap()["localId"], "30");
13185        // Middle item should have hardBreak
13186        let mid_content = items[1].content.as_ref().unwrap();
13187        assert!(mid_content.iter().any(|n| n.node_type == "hardBreak"));
13188    }
13189
13190    /// Issue #447: regression — taskList with empty localId must not inject
13191    /// a spurious paragraph.
13192    #[test]
13193    fn task_list_empty_localid_no_spurious_paragraph() {
13194        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"}]}]}]}"#;
13195        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13196        let md = adf_to_markdown(&doc).unwrap();
13197        assert!(
13198            !md.contains("{localId=}"),
13199            "empty localId should not be emitted: {md}"
13200        );
13201        let rt = markdown_to_adf(&md).unwrap();
13202        assert_eq!(
13203            rt.content.len(),
13204            1,
13205            "no spurious paragraph: {:#?}",
13206            rt.content
13207        );
13208        assert_eq!(rt.content[0].node_type, "taskList");
13209    }
13210
13211    /// Issue #447: taskList localId should be stripped when strip_local_ids is set.
13212    #[test]
13213    fn task_list_localid_stripped() {
13214        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"}]}]}]}]}"#;
13215        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13216        let opts = RenderOptions {
13217            strip_local_ids: true,
13218        };
13219        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13220        assert!(!md.contains("localId"), "localId should be stripped: {md}");
13221    }
13222
13223    /// Issue #447: taskItem with no content still emits localId.
13224    #[test]
13225    fn task_item_no_content_emits_localid() {
13226        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"}}]}]}"#;
13227        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13228        let md = adf_to_markdown(&doc).unwrap();
13229        assert!(
13230            md.contains("localId=abc"),
13231            "localId should be emitted even without content: {md}"
13232        );
13233        let rt = markdown_to_adf(&md).unwrap();
13234        let item = &rt.content[0].content.as_ref().unwrap()[0];
13235        assert_eq!(item.attrs.as_ref().unwrap()["localId"], "abc");
13236        assert!(item.content.is_none(), "should have no content");
13237    }
13238
13239    /// Issue #447: taskList localId roundtrips through block attrs.
13240    #[test]
13241    fn task_list_localid_roundtrip() {
13242        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"}]}]}]}]}"#;
13243        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13244        let md = adf_to_markdown(&doc).unwrap();
13245        assert!(
13246            md.contains("localId=tl-xyz"),
13247            "taskList localId missing: {md}"
13248        );
13249        let rt = markdown_to_adf(&md).unwrap();
13250        assert_eq!(
13251            rt.content[0].attrs.as_ref().unwrap()["localId"],
13252            "tl-xyz",
13253            "taskList localId should survive round-trip"
13254        );
13255    }
13256
13257    /// Issue #478: taskItem with paragraph wrapper (no localId) preserves wrapper on round-trip.
13258    #[test]
13259    fn task_item_paragraph_wrapper_roundtrip_no_localid() {
13260        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"}]}]}]}]}"#;
13261        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13262        let md = adf_to_markdown(&doc).unwrap();
13263        assert!(
13264            md.contains("paraLocalId=_"),
13265            "should emit paraLocalId=_ sentinel: {md}"
13266        );
13267        let rt = markdown_to_adf(&md).unwrap();
13268        let item = &rt.content[0].content.as_ref().unwrap()[0];
13269        let content = item.content.as_ref().unwrap();
13270        assert_eq!(content.len(), 1, "should have one child: {content:#?}");
13271        assert_eq!(
13272            content[0].node_type, "paragraph",
13273            "child should be a paragraph: {content:#?}"
13274        );
13275        let para_content = content[0].content.as_ref().unwrap();
13276        assert_eq!(
13277            para_content[0].text.as_deref(),
13278            Some("A task with paragraph wrapper")
13279        );
13280        // Paragraph should have no attrs (localId was absent in the original)
13281        assert!(
13282            content[0].attrs.is_none(),
13283            "paragraph should have no attrs: {:?}",
13284            content[0].attrs
13285        );
13286    }
13287
13288    /// Issue #478: taskItem with paragraph wrapper AND paraLocalId preserves both.
13289    #[test]
13290    fn task_item_paragraph_wrapper_roundtrip_with_localid() {
13291        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"}]}]}]}]}"#;
13292        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13293        let md = adf_to_markdown(&doc).unwrap();
13294        assert!(
13295            md.contains("paraLocalId=p-001"),
13296            "should emit paraLocalId=p-001: {md}"
13297        );
13298        let rt = markdown_to_adf(&md).unwrap();
13299        let item = &rt.content[0].content.as_ref().unwrap()[0];
13300        let content = item.content.as_ref().unwrap();
13301        assert_eq!(content[0].node_type, "paragraph");
13302        assert_eq!(
13303            content[0].attrs.as_ref().unwrap()["localId"],
13304            "p-001",
13305            "paragraph localId should be preserved"
13306        );
13307    }
13308
13309    /// Issue #478: taskItem WITHOUT paragraph wrapper (unwrapped inline) still round-trips correctly.
13310    #[test]
13311    fn task_item_unwrapped_inline_no_paragraph_on_roundtrip() {
13312        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"}]}]}]}"#;
13313        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13314        let md = adf_to_markdown(&doc).unwrap();
13315        assert!(
13316            !md.contains("paraLocalId"),
13317            "should NOT emit paraLocalId for unwrapped inline: {md}"
13318        );
13319        let rt = markdown_to_adf(&md).unwrap();
13320        let item = &rt.content[0].content.as_ref().unwrap()[0];
13321        let content = item.content.as_ref().unwrap();
13322        assert_eq!(
13323            content[0].node_type, "text",
13324            "should remain unwrapped: {content:#?}"
13325        );
13326    }
13327
13328    /// Issue #478: DONE taskItem with paragraph wrapper round-trips.
13329    #[test]
13330    fn task_item_done_paragraph_wrapper_roundtrip() {
13331        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"}]}]}]}]}"#;
13332        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13333        let md = adf_to_markdown(&doc).unwrap();
13334        assert!(md.contains("- [x]"), "should render as done: {md}");
13335        let rt = markdown_to_adf(&md).unwrap();
13336        let item = &rt.content[0].content.as_ref().unwrap()[0];
13337        assert_eq!(item.attrs.as_ref().unwrap()["state"], "DONE");
13338        let content = item.content.as_ref().unwrap();
13339        assert_eq!(content[0].node_type, "paragraph");
13340    }
13341
13342    /// Issue #478: mixed taskItems — some with paragraph wrapper, some without.
13343    #[test]
13344    fn task_item_mixed_paragraph_and_unwrapped_roundtrip() {
13345        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"}]}]}]}"#;
13346        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13347        let md = adf_to_markdown(&doc).unwrap();
13348        let rt = markdown_to_adf(&md).unwrap();
13349        let items = rt.content[0].content.as_ref().unwrap();
13350        assert_eq!(items.len(), 2);
13351        // First item: paragraph wrapper preserved
13352        let c1 = items[0].content.as_ref().unwrap();
13353        assert_eq!(
13354            c1[0].node_type, "paragraph",
13355            "first item should have paragraph wrapper"
13356        );
13357        // Second item: no paragraph wrapper
13358        let c2 = items[1].content.as_ref().unwrap();
13359        assert_eq!(
13360            c2[0].node_type, "text",
13361            "second item should remain unwrapped"
13362        );
13363    }
13364
13365    /// Issue #478: taskItem with paragraph wrapper containing marks round-trips.
13366    #[test]
13367    fn task_item_paragraph_wrapper_with_marks_roundtrip() {
13368        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"}]}]}]}]}]}"#;
13369        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13370        let md = adf_to_markdown(&doc).unwrap();
13371        let rt = markdown_to_adf(&md).unwrap();
13372        let item = &rt.content[0].content.as_ref().unwrap()[0];
13373        let content = item.content.as_ref().unwrap();
13374        assert_eq!(content[0].node_type, "paragraph");
13375        let para_children = content[0].content.as_ref().unwrap();
13376        assert!(
13377            para_children.len() >= 2,
13378            "paragraph should contain multiple inline nodes"
13379        );
13380    }
13381
13382    /// Issue #478: strip_local_ids suppresses the paraLocalId=_ sentinel too.
13383    #[test]
13384    fn task_item_paragraph_wrapper_stripped_with_option() {
13385        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"}]}]}]}]}"#;
13386        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13387        let opts = RenderOptions {
13388            strip_local_ids: true,
13389        };
13390        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13391        assert!(
13392            !md.contains("paraLocalId"),
13393            "paraLocalId should be stripped: {md}"
13394        );
13395        assert!(
13396            !md.contains("localId"),
13397            "all localIds should be stripped: {md}"
13398        );
13399    }
13400
13401    #[test]
13402    fn trailing_space_preserved_with_hex_localid() {
13403        // Issue #449: trailing whitespace stripped from text node
13404        // when listItem has a hex-format localId (no hyphens)
13405        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 "}]}]}]}]}"#;
13406        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13407        let md = adf_to_markdown(&doc).unwrap();
13408        let rt = markdown_to_adf(&md).unwrap();
13409        let item = &rt.content[0].content.as_ref().unwrap()[0];
13410        assert_eq!(
13411            item.attrs.as_ref().unwrap()["localId"],
13412            "aabb112233cc",
13413            "localId should round-trip"
13414        );
13415        let para = &item.content.as_ref().unwrap()[0];
13416        let inlines = para.content.as_ref().unwrap();
13417        let last = inlines.last().unwrap();
13418        assert!(
13419            last.text.as_deref().unwrap_or("").ends_with(' '),
13420            "trailing space should be preserved, got nodes: {:?}",
13421            inlines
13422                .iter()
13423                .map(|n| (&n.node_type, &n.text))
13424                .collect::<Vec<_>>()
13425        );
13426    }
13427
13428    #[test]
13429    fn extract_trailing_local_id_preserves_trailing_space() {
13430        // Issue #449: only strip the single separator space before {localId=...}
13431        let (before, lid, _) = extract_trailing_local_id("trailing space  {localId=aabb112233cc}");
13432        assert_eq!(before, "trailing space ");
13433        assert_eq!(lid.as_deref(), Some("aabb112233cc"));
13434    }
13435
13436    #[test]
13437    fn extract_trailing_local_id_no_trailing_space() {
13438        let (before, lid, _) = extract_trailing_local_id("text {localId=abc123}");
13439        assert_eq!(before, "text");
13440        assert_eq!(lid.as_deref(), Some("abc123"));
13441    }
13442
13443    #[test]
13444    fn extract_trailing_local_id_no_attrs() {
13445        let (before, lid, pid) = extract_trailing_local_id("plain text");
13446        assert_eq!(before, "plain text");
13447        assert!(lid.is_none());
13448        assert!(pid.is_none());
13449    }
13450
13451    #[test]
13452    fn list_item_localid_stripped() {
13453        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"}]}]}]}]}"#;
13454        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13455        let opts = RenderOptions {
13456            strip_local_ids: true,
13457        };
13458        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13459        assert!(!md.contains("localId"), "localId should be stripped: {md}");
13460    }
13461
13462    #[test]
13463    fn paragraph_localid_in_list_item_roundtrip() {
13464        // Issue #417: paragraph.attrs.localId dropped in listItem context
13465        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"}]}]}]}]}"#;
13466        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13467        let md = adf_to_markdown(&doc).unwrap();
13468        assert!(
13469            md.contains("paraLocalId=para-001"),
13470            "paragraph localId should be in md: {md}"
13471        );
13472        let rt = markdown_to_adf(&md).unwrap();
13473        let item = &rt.content[0].content.as_ref().unwrap()[0];
13474        assert_eq!(
13475            item.attrs.as_ref().unwrap()["localId"],
13476            "item-001",
13477            "listItem localId should survive"
13478        );
13479        let para = &item.content.as_ref().unwrap()[0];
13480        assert_eq!(
13481            para.attrs.as_ref().unwrap()["localId"],
13482            "para-001",
13483            "paragraph localId should survive round-trip"
13484        );
13485    }
13486
13487    #[test]
13488    fn paragraph_localid_in_ordered_list_item_roundtrip() {
13489        // Issue #417: paragraph localId in ordered list
13490        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"}]}]}]}]}"#;
13491        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13492        let md = adf_to_markdown(&doc).unwrap();
13493        assert!(md.contains("paraLocalId=op-001"), "md: {md}");
13494        let rt = markdown_to_adf(&md).unwrap();
13495        let item = &rt.content[0].content.as_ref().unwrap()[0];
13496        assert_eq!(item.attrs.as_ref().unwrap()["localId"], "oli-001");
13497        let para = &item.content.as_ref().unwrap()[0];
13498        assert_eq!(para.attrs.as_ref().unwrap()["localId"], "op-001");
13499    }
13500
13501    #[test]
13502    fn paragraph_localid_only_in_list_item() {
13503        // paragraph has localId but listItem does not
13504        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"}]}]}]}]}"#;
13505        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13506        let md = adf_to_markdown(&doc).unwrap();
13507        assert!(
13508            md.contains("paraLocalId=para-only"),
13509            "paragraph localId should be emitted: {md}"
13510        );
13511        let rt = markdown_to_adf(&md).unwrap();
13512        let item = &rt.content[0].content.as_ref().unwrap()[0];
13513        assert!(item.attrs.is_none(), "listItem should have no attrs");
13514        let para = &item.content.as_ref().unwrap()[0];
13515        assert_eq!(para.attrs.as_ref().unwrap()["localId"], "para-only");
13516    }
13517
13518    #[test]
13519    fn paragraph_localid_in_table_header_roundtrip() {
13520        // Issue #417: paragraph.attrs.localId dropped in tableHeader context
13521        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"}]}]}]}]}]}"#;
13522        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13523        let md = adf_to_markdown(&doc).unwrap();
13524        // Should use directive form (not pipe table) to preserve paragraph localId
13525        assert!(
13526            md.contains("localId=aaaa-aaaa"),
13527            "paragraph localId should be in md: {md}"
13528        );
13529        let rt = markdown_to_adf(&md).unwrap();
13530        let cell = &rt.content[0].content.as_ref().unwrap()[0]
13531            .content
13532            .as_ref()
13533            .unwrap()[0];
13534        let para = &cell.content.as_ref().unwrap()[0];
13535        assert_eq!(
13536            para.attrs.as_ref().unwrap()["localId"],
13537            "aaaa-aaaa",
13538            "paragraph localId should survive round-trip in tableHeader"
13539        );
13540    }
13541
13542    #[test]
13543    fn paragraph_localid_in_table_cell_roundtrip() {
13544        // Issue #417: paragraph localId in tableCell forces directive table
13545        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"}]}]}]}]}]}"#;
13546        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13547        let md = adf_to_markdown(&doc).unwrap();
13548        assert!(
13549            md.contains("localId=cell-para"),
13550            "paragraph localId should be in md: {md}"
13551        );
13552        let rt = markdown_to_adf(&md).unwrap();
13553        // Data row -> cell -> paragraph
13554        let cell = &rt.content[0].content.as_ref().unwrap()[1]
13555            .content
13556            .as_ref()
13557            .unwrap()[0];
13558        let para = &cell.content.as_ref().unwrap()[0];
13559        assert_eq!(
13560            para.attrs.as_ref().unwrap()["localId"],
13561            "cell-para",
13562            "paragraph localId should survive round-trip in tableCell"
13563        );
13564    }
13565
13566    #[test]
13567    fn nbsp_paragraph_with_localid_roundtrip() {
13568        // Issue #417: nbsp paragraph localId emitted as text instead of attrs
13569        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"nbsp-para"},"content":[{"type":"text","text":"\u00a0"}]}]}"#;
13570        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13571        let md = adf_to_markdown(&doc).unwrap();
13572        assert!(
13573            md.contains("::paragraph["),
13574            "nbsp should use directive form: {md}"
13575        );
13576        assert!(
13577            md.contains("localId=nbsp-para"),
13578            "localId should be in directive: {md}"
13579        );
13580        let rt = markdown_to_adf(&md).unwrap();
13581        let para = &rt.content[0];
13582        assert_eq!(
13583            para.attrs.as_ref().unwrap()["localId"],
13584            "nbsp-para",
13585            "localId should survive round-trip"
13586        );
13587        let text = para.content.as_ref().unwrap()[0].text.as_ref().unwrap();
13588        assert_eq!(text, "\u{00a0}", "nbsp should survive");
13589    }
13590
13591    #[test]
13592    fn empty_paragraph_with_localid_roundtrip() {
13593        // Empty paragraph directive with localId
13594        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"empty-para"}}]}"#;
13595        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13596        let md = adf_to_markdown(&doc).unwrap();
13597        assert!(
13598            md.contains("::paragraph{localId=empty-para}"),
13599            "empty paragraph should include localId in directive: {md}"
13600        );
13601        let rt = markdown_to_adf(&md).unwrap();
13602        assert_eq!(
13603            rt.content[0].attrs.as_ref().unwrap()["localId"],
13604            "empty-para"
13605        );
13606    }
13607
13608    #[test]
13609    fn paragraph_localid_stripped_from_list_item() {
13610        // strip_local_ids should also strip paraLocalId
13611        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"}]}]}]}]}"#;
13612        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13613        let opts = RenderOptions {
13614            strip_local_ids: true,
13615        };
13616        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13617        assert!(!md.contains("localId"), "localId should be stripped: {md}");
13618        assert!(
13619            !md.contains("paraLocalId"),
13620            "paraLocalId should be stripped: {md}"
13621        );
13622    }
13623
13624    #[test]
13625    fn date_directive() {
13626        let doc = markdown_to_adf("Due by :date[2026-04-15].").unwrap();
13627        let content = doc.content[0].content.as_ref().unwrap();
13628        assert_eq!(content[1].node_type, "date");
13629        // ISO date is converted to epoch milliseconds
13630        assert_eq!(
13631            content[1].attrs.as_ref().unwrap()["timestamp"],
13632            "1776211200000"
13633        );
13634    }
13635
13636    #[test]
13637    fn adf_date_to_markdown() {
13638        // ADF dates use epoch ms; renderer converts back to ISO with timestamp attr
13639        let doc = AdfDocument {
13640            version: 1,
13641            doc_type: "doc".to_string(),
13642            content: vec![AdfNode::paragraph(vec![AdfNode::date("1776211200000")])],
13643        };
13644        let md = adf_to_markdown(&doc).unwrap();
13645        assert!(md.contains(":date[2026-04-15]{timestamp=1776211200000}"));
13646    }
13647
13648    #[test]
13649    fn adf_date_iso_passthrough() {
13650        // If ADF already has ISO date (legacy), pass through
13651        let doc = AdfDocument {
13652            version: 1,
13653            doc_type: "doc".to_string(),
13654            content: vec![AdfNode::paragraph(vec![AdfNode::date("2026-04-15")])],
13655        };
13656        let md = adf_to_markdown(&doc).unwrap();
13657        assert!(md.contains(":date[2026-04-15]{timestamp=2026-04-15}"));
13658    }
13659
13660    #[test]
13661    fn round_trip_date() {
13662        let md = "Due by :date[2026-04-15].\n";
13663        let doc = markdown_to_adf(md).unwrap();
13664        let result = adf_to_markdown(&doc).unwrap();
13665        assert!(result.contains(":date[2026-04-15]"));
13666    }
13667
13668    #[test]
13669    fn round_trip_date_non_midnight_timestamp() {
13670        // Issue #409: non-midnight timestamps must survive round-trip
13671        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000"}}]}]}"#;
13672        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13673        let md = adf_to_markdown(&doc).unwrap();
13674        // JFM should include the original timestamp
13675        assert!(
13676            md.contains("timestamp=1700000000000"),
13677            "JFM should preserve original timestamp: {md}"
13678        );
13679        // Round-trip back to ADF
13680        let doc2 = markdown_to_adf(&md).unwrap();
13681        let content = doc2.content[0].content.as_ref().unwrap();
13682        assert_eq!(
13683            content[0].attrs.as_ref().unwrap()["timestamp"],
13684            "1700000000000",
13685            "Round-trip must preserve original non-midnight timestamp"
13686        );
13687    }
13688
13689    #[test]
13690    fn date_epoch_ms_passthrough() {
13691        // If JFM date is already epoch ms, pass through
13692        let doc = markdown_to_adf("Due by :date[1776211200000].").unwrap();
13693        let content = doc.content[0].content.as_ref().unwrap();
13694        assert_eq!(
13695            content[1].attrs.as_ref().unwrap()["timestamp"],
13696            "1776211200000"
13697        );
13698    }
13699
13700    #[test]
13701    fn date_timestamp_attr_preferred_over_content() {
13702        // When timestamp attr is present, it takes priority over the display date
13703        let md = ":date[2023-11-14]{timestamp=1700000000000}\n";
13704        let doc = markdown_to_adf(md).unwrap();
13705        let content = doc.content[0].content.as_ref().unwrap();
13706        assert_eq!(
13707            content[0].attrs.as_ref().unwrap()["timestamp"],
13708            "1700000000000",
13709            "timestamp attr should be used directly"
13710        );
13711    }
13712
13713    #[test]
13714    fn date_without_timestamp_attr_backward_compat() {
13715        // Legacy JFM without timestamp attr still works via iso_date_to_epoch_ms
13716        let md = ":date[2026-04-15]\n";
13717        let doc = markdown_to_adf(md).unwrap();
13718        let content = doc.content[0].content.as_ref().unwrap();
13719        assert_eq!(
13720            content[0].attrs.as_ref().unwrap()["timestamp"],
13721            "1776211200000",
13722            "Should fall back to computing timestamp from date string"
13723        );
13724    }
13725
13726    #[test]
13727    fn date_with_local_id_and_timestamp() {
13728        // Both localId and timestamp should round-trip
13729        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
13730        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13731        let md = adf_to_markdown(&doc).unwrap();
13732        assert!(
13733            md.contains("timestamp=1700000000000"),
13734            "Should contain timestamp: {md}"
13735        );
13736        assert!(md.contains("localId=d-001"), "Should contain localId: {md}");
13737        // Round-trip
13738        let doc2 = markdown_to_adf(&md).unwrap();
13739        let content = doc2.content[0].content.as_ref().unwrap();
13740        let attrs = content[0].attrs.as_ref().unwrap();
13741        assert_eq!(attrs["timestamp"], "1700000000000");
13742        assert_eq!(attrs["localId"], "d-001");
13743    }
13744
13745    #[test]
13746    fn mention_directive() {
13747        let doc = markdown_to_adf("Assigned to :mention[Alice]{id=abc123}.").unwrap();
13748        let content = doc.content[0].content.as_ref().unwrap();
13749        assert_eq!(content[1].node_type, "mention");
13750        assert_eq!(content[1].attrs.as_ref().unwrap()["id"], "abc123");
13751        assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "Alice");
13752    }
13753
13754    #[test]
13755    fn adf_mention_to_markdown() {
13756        let doc = AdfDocument {
13757            version: 1,
13758            doc_type: "doc".to_string(),
13759            content: vec![AdfNode::paragraph(vec![AdfNode::mention(
13760                "abc123", "Alice",
13761            )])],
13762        };
13763        let md = adf_to_markdown(&doc).unwrap();
13764        assert!(md.contains(":mention[Alice]{id=abc123}"));
13765    }
13766
13767    #[test]
13768    fn round_trip_mention() {
13769        let md = "Assigned to :mention[Alice]{id=abc123}.\n";
13770        let doc = markdown_to_adf(md).unwrap();
13771        let result = adf_to_markdown(&doc).unwrap();
13772        assert!(result.contains(":mention[Alice]{id=abc123}"));
13773    }
13774
13775    #[test]
13776    fn mention_with_empty_access_level_round_trips() {
13777        // Issue #363: accessLevel="" produces accessLevel= which failed to parse
13778        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13779          {"type":"mention","attrs":{"id":"61921b41c15977006af2b1d1","text":"@Javier Inchausti","accessLevel":""}}
13780        ]}]}"#;
13781        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13782
13783        let md = adf_to_markdown(&doc).unwrap();
13784        let round_tripped = markdown_to_adf(&md).unwrap();
13785        let mention = &round_tripped.content[0].content.as_ref().unwrap()[0];
13786        assert_eq!(
13787            mention.node_type, "mention",
13788            "mention with empty accessLevel was not parsed as mention, got: {}",
13789            mention.node_type
13790        );
13791    }
13792
13793    #[test]
13794    fn span_with_color() {
13795        let doc = markdown_to_adf("This is :span[red text]{color=#ff5630}.").unwrap();
13796        let content = doc.content[0].content.as_ref().unwrap();
13797        assert_eq!(content[1].node_type, "text");
13798        assert_eq!(content[1].text.as_deref(), Some("red text"));
13799        let marks = content[1].marks.as_ref().unwrap();
13800        assert_eq!(marks[0].mark_type, "textColor");
13801    }
13802
13803    #[test]
13804    fn adf_text_color_to_markdown() {
13805        let doc = AdfDocument {
13806            version: 1,
13807            doc_type: "doc".to_string(),
13808            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
13809                "red text",
13810                vec![AdfMark::text_color("#ff5630")],
13811            )])],
13812        };
13813        let md = adf_to_markdown(&doc).unwrap();
13814        assert!(md.contains(":span[red text]{color=#ff5630}"));
13815    }
13816
13817    #[test]
13818    fn round_trip_span_color() {
13819        let md = "This is :span[red text]{color=#ff5630}.\n";
13820        let doc = markdown_to_adf(md).unwrap();
13821        let result = adf_to_markdown(&doc).unwrap();
13822        assert!(result.contains(":span[red text]{color=#ff5630}"));
13823    }
13824
13825    #[test]
13826    fn text_color_and_link_marks_both_preserved() {
13827        // Issue #405: text with both textColor and link marks loses link on round-trip
13828        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13829          {"type":"text","text":"red link","marks":[
13830            {"type":"link","attrs":{"href":"https://example.com"}},
13831            {"type":"textColor","attrs":{"color":"#ff0000"}}
13832          ]}
13833        ]}]}"##;
13834        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13835        let md = adf_to_markdown(&doc).unwrap();
13836        assert!(
13837            md.contains(":span[red link]{color=#ff0000}"),
13838            "JFM should contain span with color, got: {md}"
13839        );
13840        assert!(
13841            md.contains("](https://example.com)"),
13842            "JFM should contain link href, got: {md}"
13843        );
13844        // Full round-trip: both marks survive
13845        let rt = markdown_to_adf(&md).unwrap();
13846        let text_node = &rt.content[0].content.as_ref().unwrap()[0];
13847        let marks = text_node.marks.as_ref().expect("should have marks");
13848        assert!(
13849            marks.iter().any(|m| m.mark_type == "textColor"),
13850            "should have textColor mark, got: {:?}",
13851            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
13852        );
13853        assert!(
13854            marks.iter().any(|m| m.mark_type == "link"),
13855            "should have link mark, got: {:?}",
13856            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
13857        );
13858        // Verify attribute values survive
13859        let link_mark = marks.iter().find(|m| m.mark_type == "link").unwrap();
13860        assert_eq!(
13861            link_mark.attrs.as_ref().unwrap()["href"],
13862            "https://example.com"
13863        );
13864        let color_mark = marks.iter().find(|m| m.mark_type == "textColor").unwrap();
13865        assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#ff0000");
13866    }
13867
13868    #[test]
13869    fn bg_color_and_link_marks_both_preserved() {
13870        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13871          {"type":"text","text":"highlighted link","marks":[
13872            {"type":"link","attrs":{"href":"https://example.com"}},
13873            {"type":"backgroundColor","attrs":{"color":"#ffff00"}}
13874          ]}
13875        ]}]}"##;
13876        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13877        let md = adf_to_markdown(&doc).unwrap();
13878        assert!(md.contains("bg=#ffff00"), "should have bg color: {md}");
13879        assert!(
13880            md.contains("](https://example.com)"),
13881            "should have link: {md}"
13882        );
13883        let rt = markdown_to_adf(&md).unwrap();
13884        let text_node = &rt.content[0].content.as_ref().unwrap()[0];
13885        let marks = text_node.marks.as_ref().expect("should have marks");
13886        assert!(marks.iter().any(|m| m.mark_type == "backgroundColor"));
13887        assert!(marks.iter().any(|m| m.mark_type == "link"));
13888    }
13889
13890    #[test]
13891    fn text_color_link_and_strong_rendering() {
13892        // Verify textColor + link + strong renders all three formatting elements
13893        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13894          {"type":"text","text":"bold red link","marks":[
13895            {"type":"strong"},
13896            {"type":"link","attrs":{"href":"https://example.com"}},
13897            {"type":"textColor","attrs":{"color":"#ff0000"}}
13898          ]}
13899        ]}]}"##;
13900        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13901        let md = adf_to_markdown(&doc).unwrap();
13902        assert!(
13903            md.starts_with("**") && md.trim().ends_with("**"),
13904            "should have bold wrapping: {md}"
13905        );
13906        assert!(md.contains("color=#ff0000"), "should have color: {md}");
13907        assert!(
13908            md.contains("](https://example.com)"),
13909            "should have link: {md}"
13910        );
13911    }
13912
13913    #[test]
13914    fn subsup_and_link_marks_both_preserved() {
13915        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13916          {"type":"text","text":"note","marks":[
13917            {"type":"link","attrs":{"href":"https://example.com"}},
13918            {"type":"subsup","attrs":{"type":"sup"}}
13919          ]}
13920        ]}]}"#;
13921        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13922        let md = adf_to_markdown(&doc).unwrap();
13923        assert!(md.contains("sup"), "should have sup: {md}");
13924        assert!(
13925            md.contains("](https://example.com)"),
13926            "should have link: {md}"
13927        );
13928        let rt = markdown_to_adf(&md).unwrap();
13929        let text_node = &rt.content[0].content.as_ref().unwrap()[0];
13930        let marks = text_node.marks.as_ref().expect("should have marks");
13931        assert!(marks.iter().any(|m| m.mark_type == "subsup"));
13932        assert!(marks.iter().any(|m| m.mark_type == "link"));
13933    }
13934
13935    #[test]
13936    fn text_color_without_link_unchanged() {
13937        // Regression guard: textColor without link should still work
13938        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13939          {"type":"text","text":"just red","marks":[
13940            {"type":"textColor","attrs":{"color":"#ff0000"}}
13941          ]}
13942        ]}]}"##;
13943        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13944        let md = adf_to_markdown(&doc).unwrap();
13945        assert!(md.contains(":span[just red]{color=#ff0000}"), "md: {md}");
13946        assert!(!md.contains("](http"), "should NOT have link syntax: {md}");
13947    }
13948
13949    #[test]
13950    fn inline_extension_directive() {
13951        let doc =
13952            markdown_to_adf("See :extension[fallback]{type=com.app key=widget} here.").unwrap();
13953        let content = doc.content[0].content.as_ref().unwrap();
13954        assert_eq!(content[1].node_type, "inlineExtension");
13955        assert_eq!(
13956            content[1].attrs.as_ref().unwrap()["extensionType"],
13957            "com.app"
13958        );
13959        assert_eq!(content[1].attrs.as_ref().unwrap()["extensionKey"], "widget");
13960    }
13961
13962    #[test]
13963    fn adf_inline_extension_to_markdown() {
13964        let doc = AdfDocument {
13965            version: 1,
13966            doc_type: "doc".to_string(),
13967            content: vec![AdfNode::paragraph(vec![AdfNode::inline_extension(
13968                "com.app",
13969                "widget",
13970                Some("fallback"),
13971            )])],
13972        };
13973        let md = adf_to_markdown(&doc).unwrap();
13974        assert!(md.contains(":extension[fallback]{type=com.app key=widget}"));
13975    }
13976
13977    // ── Helper function tests ──────────────────────────────────────────
13978
13979    #[test]
13980    fn parse_ordered_list_marker_valid() {
13981        let result = parse_ordered_list_marker("1. Hello");
13982        assert_eq!(result, Some((1, "Hello")));
13983    }
13984
13985    #[test]
13986    fn parse_ordered_list_marker_high_number() {
13987        let result = parse_ordered_list_marker("42. Item");
13988        assert_eq!(result, Some((42, "Item")));
13989    }
13990
13991    #[test]
13992    fn parse_ordered_list_marker_not_a_list() {
13993        assert!(parse_ordered_list_marker("not a list").is_none());
13994        assert!(parse_ordered_list_marker("1.no space").is_none());
13995    }
13996
13997    #[test]
13998    fn is_list_start_various() {
13999        assert!(is_list_start("- item"));
14000        assert!(is_list_start("* item"));
14001        assert!(is_list_start("+ item"));
14002        assert!(is_list_start("1. item"));
14003        assert!(!is_list_start("not a list"));
14004    }
14005
14006    #[test]
14007    fn is_horizontal_rule_various() {
14008        assert!(is_horizontal_rule("---"));
14009        assert!(is_horizontal_rule("***"));
14010        assert!(is_horizontal_rule("___"));
14011        assert!(is_horizontal_rule("------"));
14012        assert!(!is_horizontal_rule("--"));
14013        assert!(!is_horizontal_rule("abc"));
14014    }
14015
14016    #[test]
14017    fn is_table_separator_valid() {
14018        assert!(is_table_separator("| --- | --- |"));
14019        assert!(is_table_separator("|:---:|:---|"));
14020        assert!(!is_table_separator("no pipes here"));
14021    }
14022
14023    #[test]
14024    fn parse_table_row_cells() {
14025        let cells = parse_table_row("| A | B | C |");
14026        assert_eq!(cells, vec!["A", "B", "C"]);
14027    }
14028
14029    #[test]
14030    fn parse_table_row_escaped_pipe_in_cell() {
14031        // Issue #579: `\|` inside a cell is a literal pipe, not a column separator.
14032        let cells = parse_table_row(r"| a\|b | c |");
14033        assert_eq!(cells, vec!["a|b", "c"]);
14034    }
14035
14036    #[test]
14037    fn parse_table_row_escaped_pipe_in_code_span() {
14038        // Issue #579: `\|` inside an inline code span is unescaped at the row level.
14039        let cells = parse_table_row(r"| `parser.decode[T\|json]` | other |");
14040        assert_eq!(cells, vec!["`parser.decode[T|json]`", "other"]);
14041    }
14042
14043    #[test]
14044    fn parse_table_row_preserves_other_backslashes() {
14045        // Only `\|` is special at the row-splitting level; other backslashes pass through.
14046        let cells = parse_table_row(r"| a\\b | c\*d |");
14047        assert_eq!(cells, vec![r"a\\b", r"c\*d"]);
14048    }
14049
14050    #[test]
14051    fn parse_image_syntax_valid() {
14052        let result = parse_image_syntax("![alt](url)");
14053        assert_eq!(result, Some(("alt", "url")));
14054    }
14055
14056    #[test]
14057    fn parse_image_syntax_not_image() {
14058        assert!(parse_image_syntax("not an image").is_none());
14059    }
14060
14061    // ── find_closing_paren tests ────────────────────────────────────
14062
14063    #[test]
14064    fn find_closing_paren_simple() {
14065        assert_eq!(find_closing_paren("(hello)", 0), Some(6));
14066    }
14067
14068    #[test]
14069    fn find_closing_paren_nested() {
14070        assert_eq!(find_closing_paren("(a(b)c)", 0), Some(6));
14071    }
14072
14073    #[test]
14074    fn find_closing_paren_unmatched() {
14075        assert_eq!(find_closing_paren("(no close", 0), None);
14076    }
14077
14078    #[test]
14079    fn find_closing_paren_offset() {
14080        // Start scanning from the second '('
14081        assert_eq!(find_closing_paren("xx(inner)", 2), Some(8));
14082    }
14083
14084    // ── Parentheses-in-URL tests (issue #509) ──────────────────────
14085
14086    #[test]
14087    fn try_parse_link_url_with_parens() {
14088        let input = "[here](https://example.com/faq#access-(permissions)-rest)";
14089        let result = try_parse_link(input, 0);
14090        assert_eq!(
14091            result,
14092            Some((
14093                input.len(),
14094                "here",
14095                "https://example.com/faq#access-(permissions)-rest"
14096            ))
14097        );
14098    }
14099
14100    #[test]
14101    fn try_parse_link_url_no_parens() {
14102        let input = "[text](https://example.com)";
14103        let result = try_parse_link(input, 0);
14104        assert_eq!(result, Some((input.len(), "text", "https://example.com")));
14105    }
14106
14107    #[test]
14108    fn try_parse_link_url_with_multiple_nested_parens() {
14109        let input = "[x](http://en.wikipedia.org/wiki/Foo_(bar_(baz)))";
14110        let result = try_parse_link(input, 0);
14111        assert_eq!(
14112            result,
14113            Some((
14114                input.len(),
14115                "x",
14116                "http://en.wikipedia.org/wiki/Foo_(bar_(baz))"
14117            ))
14118        );
14119    }
14120
14121    #[test]
14122    fn parse_image_syntax_url_with_parens() {
14123        let result = parse_image_syntax("![alt](https://example.com/page_(1))");
14124        assert_eq!(result, Some(("alt", "https://example.com/page_(1)")));
14125    }
14126
14127    #[test]
14128    fn parse_image_syntax_url_no_parens() {
14129        let result = parse_image_syntax("![alt](https://example.com)");
14130        assert_eq!(result, Some(("alt", "https://example.com")));
14131    }
14132
14133    #[test]
14134    fn link_with_parens_round_trip() {
14135        let href = "https://example.com/faq#I-need-access-(permissions)-added-in-Monitor";
14136        let mut text_node = AdfNode::text("here");
14137        text_node.marks = Some(vec![AdfMark::link(href)]);
14138        let adf_input = AdfDocument {
14139            version: 1,
14140            doc_type: "doc".to_string(),
14141            content: vec![AdfNode::paragraph(vec![text_node])],
14142        };
14143
14144        let jfm = adf_to_markdown(&adf_input).unwrap();
14145        let adf_output = markdown_to_adf(&jfm).unwrap();
14146
14147        // Extract the href from the round-tripped ADF
14148        let para = &adf_output.content[0];
14149        let text_node = &para.content.as_ref().unwrap()[0];
14150        let mark = &text_node.marks.as_ref().unwrap()[0];
14151        let result_href = mark.attrs.as_ref().unwrap()["href"].as_str().unwrap();
14152
14153        assert_eq!(result_href, href);
14154    }
14155
14156    #[test]
14157    fn flush_plain_empty_range() {
14158        let mut nodes = Vec::new();
14159        flush_plain("hello", 3, 3, &mut nodes);
14160        assert!(nodes.is_empty());
14161    }
14162
14163    #[test]
14164    fn add_mark_to_unmarked_node() {
14165        let mut node = AdfNode::text("test");
14166        add_mark(&mut node, AdfMark::strong());
14167        assert_eq!(node.marks.as_ref().unwrap().len(), 1);
14168    }
14169
14170    #[test]
14171    fn add_mark_to_marked_node() {
14172        let mut node = AdfNode::text_with_marks("test", vec![AdfMark::strong()]);
14173        add_mark(&mut node, AdfMark::em());
14174        assert_eq!(node.marks.as_ref().unwrap().len(), 2);
14175    }
14176
14177    // ── Directive table tests ──────────────────────────────────────
14178
14179    #[test]
14180    fn directive_table_basic() {
14181        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";
14182        let doc = markdown_to_adf(md).unwrap();
14183        assert_eq!(doc.content[0].node_type, "table");
14184        let rows = doc.content[0].content.as_ref().unwrap();
14185        assert_eq!(rows.len(), 2);
14186        assert_eq!(
14187            rows[0].content.as_ref().unwrap()[0].node_type,
14188            "tableHeader"
14189        );
14190        assert_eq!(rows[1].content.as_ref().unwrap()[0].node_type, "tableCell");
14191    }
14192
14193    #[test]
14194    fn directive_table_with_block_content() {
14195        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";
14196        let doc = markdown_to_adf(md).unwrap();
14197        let rows = doc.content[0].content.as_ref().unwrap();
14198        let cell = &rows[0].content.as_ref().unwrap()[0];
14199        // Cell should have block content (paragraph + bullet list)
14200        let content = cell.content.as_ref().unwrap();
14201        assert!(content.len() >= 2);
14202        assert_eq!(content[1].node_type, "bulletList");
14203    }
14204
14205    #[test]
14206    fn directive_table_with_cell_attrs() {
14207        let md = "::::table\n:::tr\n:::td{colspan=2 bg=#DEEBFF}\nSpanning cell\n:::\n:::\n::::\n";
14208        let doc = markdown_to_adf(md).unwrap();
14209        let cell = &doc.content[0].content.as_ref().unwrap()[0]
14210            .content
14211            .as_ref()
14212            .unwrap()[0];
14213        let attrs = cell.attrs.as_ref().unwrap();
14214        assert_eq!(attrs["colspan"], 2);
14215        assert_eq!(attrs["background"], "#DEEBFF");
14216    }
14217
14218    #[test]
14219    fn directive_table_with_css_var_background() {
14220        let bg = "var(--ds-background-accent-gray-subtlest, var(--ds-background-accent-gray-subtlest, #F1F2F4))";
14221        let md = format!("::::table\n:::tr\n:::th{{bg=\"{bg}\"}}\nHeader\n:::\n:::\n::::\n");
14222        let doc = markdown_to_adf(&md).unwrap();
14223        let row = &doc.content[0].content.as_ref().unwrap()[0];
14224        let cells = row.content.as_ref().unwrap();
14225        assert_eq!(cells.len(), 1, "row must have at least one cell");
14226        let attrs = cells[0].attrs.as_ref().unwrap();
14227        assert_eq!(attrs["background"], bg);
14228    }
14229
14230    #[test]
14231    fn css_var_background_round_trips() {
14232        let bg = "var(--ds-background-accent-gray-subtlest, #F1F2F4)";
14233        let adf = AdfDocument {
14234            version: 1,
14235            doc_type: "doc".to_string(),
14236            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
14237                AdfNode::table_header_with_attrs(
14238                    vec![AdfNode::paragraph(vec![AdfNode::text("Header")])],
14239                    serde_json::json!({"background": bg}),
14240                ),
14241            ])])],
14242        };
14243        let md = adf_to_markdown(&adf).unwrap();
14244        assert!(
14245            md.contains(&format!("bg=\"{bg}\"")),
14246            "bg value must be quoted in markdown: {md}"
14247        );
14248
14249        let round_tripped = markdown_to_adf(&md).unwrap();
14250        let row = &round_tripped.content[0].content.as_ref().unwrap()[0];
14251        let cells = row.content.as_ref().unwrap();
14252        assert_eq!(cells.len(), 1, "round-tripped row must have one cell");
14253        let rt_attrs = cells[0].attrs.as_ref().unwrap();
14254        assert_eq!(rt_attrs["background"], bg);
14255    }
14256
14257    #[test]
14258    fn directive_table_with_table_attrs() {
14259        let md = "::::table{layout=wide numbered}\n:::tr\n:::td\nCell\n:::\n:::\n::::\n";
14260        let doc = markdown_to_adf(md).unwrap();
14261        let attrs = doc.content[0].attrs.as_ref().unwrap();
14262        assert_eq!(attrs["layout"], "wide");
14263        assert_eq!(attrs["isNumberColumnEnabled"], true);
14264    }
14265
14266    #[test]
14267    fn adf_table_with_block_content_renders_directive_form() {
14268        // Table with a bullet list in a cell → should render as ::::table directive
14269        let doc = AdfDocument {
14270            version: 1,
14271            doc_type: "doc".to_string(),
14272            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
14273                AdfNode::table_cell(vec![
14274                    AdfNode::paragraph(vec![AdfNode::text("Cell with list:")]),
14275                    AdfNode::bullet_list(vec![AdfNode::list_item(vec![AdfNode::paragraph(vec![
14276                        AdfNode::text("Item 1"),
14277                    ])])]),
14278                ]),
14279            ])])],
14280        };
14281        let md = adf_to_markdown(&doc).unwrap();
14282        assert!(md.contains("::::table"));
14283        assert!(md.contains(":::td"));
14284        assert!(md.contains("- Item 1"));
14285    }
14286
14287    #[test]
14288    fn adf_table_inline_only_renders_pipe_form() {
14289        // Table with only inline content → pipe table
14290        let doc = AdfDocument {
14291            version: 1,
14292            doc_type: "doc".to_string(),
14293            content: vec![AdfNode::table(vec![
14294                AdfNode::table_row(vec![
14295                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
14296                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
14297                ]),
14298                AdfNode::table_row(vec![
14299                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C1")])]),
14300                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
14301                ]),
14302            ])],
14303        };
14304        let md = adf_to_markdown(&doc).unwrap();
14305        assert!(md.contains("| H1 | H2 |"));
14306        assert!(!md.contains("::::table"));
14307    }
14308
14309    #[test]
14310    fn adf_table_header_outside_first_row_renders_directive() {
14311        let doc = AdfDocument {
14312            version: 1,
14313            doc_type: "doc".to_string(),
14314            content: vec![AdfNode::table(vec![
14315                AdfNode::table_row(vec![
14316                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H")])]),
14317                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C")])]),
14318                ]),
14319                AdfNode::table_row(vec![
14320                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
14321                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
14322                ]),
14323            ])],
14324        };
14325        let md = adf_to_markdown(&doc).unwrap();
14326        assert!(md.contains("::::table"));
14327        assert!(md.contains(":::th"));
14328    }
14329
14330    #[test]
14331    fn adf_table_cell_attrs_rendered() {
14332        let doc = AdfDocument {
14333            version: 1,
14334            doc_type: "doc".to_string(),
14335            content: vec![AdfNode::table(vec![
14336                AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
14337                    AdfNode::text("H"),
14338                ])])]),
14339                AdfNode::table_row(vec![AdfNode::table_cell_with_attrs(
14340                    vec![AdfNode::paragraph(vec![AdfNode::text("C")])],
14341                    serde_json::json!({"background": "#DEEBFF", "colspan": 2}),
14342                )]),
14343            ])],
14344        };
14345        let md = adf_to_markdown(&doc).unwrap();
14346        assert!(md.contains("{colspan=2 bg=#DEEBFF}"));
14347    }
14348
14349    // ── Pipe table cell attrs tests ────────────────────────────────
14350
14351    #[test]
14352    fn pipe_table_cell_attrs() {
14353        let md = "| H1 | H2 |\n|---|---|\n| {bg=#DEEBFF} highlighted | normal |\n";
14354        let doc = markdown_to_adf(md).unwrap();
14355        let rows = doc.content[0].content.as_ref().unwrap();
14356        let cell = &rows[1].content.as_ref().unwrap()[0];
14357        let attrs = cell.attrs.as_ref().unwrap();
14358        assert_eq!(attrs["background"], "#DEEBFF");
14359    }
14360
14361    #[test]
14362    fn pipe_table_cell_colspan() {
14363        let md = "| H1 | H2 |\n|---|---|\n| {colspan=2} spanning |\n";
14364        let doc = markdown_to_adf(md).unwrap();
14365        let rows = doc.content[0].content.as_ref().unwrap();
14366        let cell = &rows[1].content.as_ref().unwrap()[0];
14367        let attrs = cell.attrs.as_ref().unwrap();
14368        assert_eq!(attrs["colspan"], 2);
14369    }
14370
14371    #[test]
14372    fn trailing_space_after_mention_in_table_cell_preserved() {
14373        // Issue #372: trailing space after mention in table cell was dropped
14374        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":[
14375          {"type":"mention","attrs":{"id":"aaa","text":"@Rob"}},
14376          {"type":"text","text":" "}
14377        ]}]}]}]}]}"#;
14378        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14379        let md = adf_to_markdown(&doc).unwrap();
14380        let round_tripped = markdown_to_adf(&md).unwrap();
14381        let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14382            .content
14383            .as_ref()
14384            .unwrap()[0];
14385        let para = &cell.content.as_ref().unwrap()[0];
14386        let inlines = para.content.as_ref().unwrap();
14387        assert!(
14388            inlines.len() >= 2,
14389            "expected mention + text(' ') nodes, got {} nodes: {:?}",
14390            inlines.len(),
14391            inlines.iter().map(|n| &n.node_type).collect::<Vec<_>>()
14392        );
14393        assert_eq!(inlines[0].node_type, "mention");
14394        assert_eq!(inlines[1].node_type, "text");
14395        assert_eq!(inlines[1].text.as_deref(), Some(" "));
14396    }
14397
14398    // ── Column alignment tests ─────────────────────────────────────
14399
14400    #[test]
14401    fn pipe_table_column_alignment() {
14402        let md = "| Left | Center | Right |\n|:---|:---:|---:|\n| L | C | R |\n";
14403        let doc = markdown_to_adf(md).unwrap();
14404        let rows = doc.content[0].content.as_ref().unwrap();
14405        // Header row
14406        let h_cells = rows[0].content.as_ref().unwrap();
14407        // Left → no mark
14408        assert!(h_cells[0].content.as_ref().unwrap()[0].marks.is_none());
14409        // Center → alignment center
14410        let center_marks = h_cells[1].content.as_ref().unwrap()[0]
14411            .marks
14412            .as_ref()
14413            .unwrap();
14414        assert_eq!(center_marks[0].attrs.as_ref().unwrap()["align"], "center");
14415        // Right → alignment end
14416        let right_marks = h_cells[2].content.as_ref().unwrap()[0]
14417            .marks
14418            .as_ref()
14419            .unwrap();
14420        assert_eq!(right_marks[0].attrs.as_ref().unwrap()["align"], "end");
14421    }
14422
14423    #[test]
14424    fn adf_table_alignment_roundtrip() {
14425        let doc = AdfDocument {
14426            version: 1,
14427            doc_type: "doc".to_string(),
14428            content: vec![AdfNode::table(vec![
14429                AdfNode::table_row(vec![
14430                    AdfNode::table_header(vec![{
14431                        let mut p = AdfNode::paragraph(vec![AdfNode::text("Center")]);
14432                        p.marks = Some(vec![AdfMark::alignment("center")]);
14433                        p
14434                    }]),
14435                    AdfNode::table_header(vec![{
14436                        let mut p = AdfNode::paragraph(vec![AdfNode::text("Right")]);
14437                        p.marks = Some(vec![AdfMark::alignment("end")]);
14438                        p
14439                    }]),
14440                ]),
14441                AdfNode::table_row(vec![
14442                    AdfNode::table_cell(vec![{
14443                        let mut p = AdfNode::paragraph(vec![AdfNode::text("C")]);
14444                        p.marks = Some(vec![AdfMark::alignment("center")]);
14445                        p
14446                    }]),
14447                    AdfNode::table_cell(vec![{
14448                        let mut p = AdfNode::paragraph(vec![AdfNode::text("R")]);
14449                        p.marks = Some(vec![AdfMark::alignment("end")]);
14450                        p
14451                    }]),
14452                ]),
14453            ])],
14454        };
14455        let md = adf_to_markdown(&doc).unwrap();
14456        assert!(md.contains(":---:"));
14457        assert!(md.contains("---:"));
14458    }
14459
14460    // ── Panel custom attrs tests ───────────────────────────────────
14461
14462    #[test]
14463    fn panel_custom_attrs_round_trip() {
14464        let md = ":::panel{type=custom icon=\":star:\" color=\"#DEEBFF\"}\nContent\n:::\n";
14465        let doc = markdown_to_adf(md).unwrap();
14466        let panel = &doc.content[0];
14467        let attrs = panel.attrs.as_ref().unwrap();
14468        assert_eq!(attrs["panelType"], "custom");
14469        assert_eq!(attrs["panelIcon"], ":star:");
14470        assert_eq!(attrs["panelColor"], "#DEEBFF");
14471
14472        let result = adf_to_markdown(&doc).unwrap();
14473        assert!(result.contains("type=custom"));
14474        assert!(result.contains("icon="));
14475        assert!(result.contains("color="));
14476    }
14477
14478    // ── Block card with attrs tests ────────────────────────────────
14479
14480    #[test]
14481    fn block_card_with_layout() {
14482        let md = "::card[https://example.com]{layout=wide}\n";
14483        let doc = markdown_to_adf(md).unwrap();
14484        let attrs = doc.content[0].attrs.as_ref().unwrap();
14485        assert_eq!(attrs["layout"], "wide");
14486
14487        let result = adf_to_markdown(&doc).unwrap();
14488        assert!(result.contains("::card[https://example.com]{layout=wide}"));
14489    }
14490
14491    // ── Extension params test ──────────────────────────────────────
14492
14493    #[test]
14494    fn extension_with_params() {
14495        let md = r#"::extension{type=com.atlassian.macro key=jira-chart params='{"jql":"project=PROJ"}'}"#;
14496        let doc = markdown_to_adf(&format!("{md}\n")).unwrap();
14497        let attrs = doc.content[0].attrs.as_ref().unwrap();
14498        assert_eq!(attrs["parameters"]["jql"], "project=PROJ");
14499    }
14500
14501    #[test]
14502    fn leaf_extension_layout_preserved_in_roundtrip() {
14503        // Issue #381: layout attr on extension nodes was dropped
14504        let adf_json = r#"{"version":1,"type":"doc","content":[
14505          {"type":"extension","attrs":{"extensionType":"com.atlassian.confluence.macro.core","extensionKey":"toc","layout":"default","parameters":{}}}
14506        ]}"#;
14507        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14508        let md = adf_to_markdown(&doc).unwrap();
14509        assert!(
14510            md.contains("layout=default"),
14511            "JFM should contain layout=default, got: {md}"
14512        );
14513        let round_tripped = markdown_to_adf(&md).unwrap();
14514        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14515        assert_eq!(attrs["layout"], "default", "layout should be preserved");
14516        assert_eq!(attrs["extensionKey"], "toc");
14517    }
14518
14519    #[test]
14520    fn bodied_extension_layout_preserved_in_roundtrip() {
14521        // Bodied extension with layout
14522        let adf_json = r#"{"version":1,"type":"doc","content":[
14523          {"type":"bodiedExtension","attrs":{"extensionType":"com.atlassian.macro","extensionKey":"expand","layout":"wide"},
14524           "content":[{"type":"paragraph","content":[{"type":"text","text":"inner"}]}]}
14525        ]}"#;
14526        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14527        let md = adf_to_markdown(&doc).unwrap();
14528        assert!(
14529            md.contains("layout=wide"),
14530            "JFM should contain layout=wide, got: {md}"
14531        );
14532        let round_tripped = markdown_to_adf(&md).unwrap();
14533        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14534        assert_eq!(attrs["layout"], "wide", "layout should be preserved");
14535    }
14536
14537    #[test]
14538    fn bodied_extension_parameters_preserved_in_roundtrip() {
14539        // Issue #473: parameters block inside bodiedExtension.attrs was dropped
14540        let adf_json = r#"{"version":1,"type":"doc","content":[
14541          {"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":{}}},
14542           "content":[{"type":"paragraph","content":[{"type":"text","text":"Content inside bodied extension"}]}]}
14543        ]}"#;
14544        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14545        let md = adf_to_markdown(&doc).unwrap();
14546        assert!(
14547            md.contains("params="),
14548            "JFM should contain params attribute, got: {md}"
14549        );
14550        let round_tripped = markdown_to_adf(&md).unwrap();
14551        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14552        assert_eq!(
14553            attrs["parameters"]["macroMetadata"]["title"], "Page Properties",
14554            "parameters should be preserved in round-trip"
14555        );
14556        assert_eq!(attrs["extensionKey"], "details");
14557        assert_eq!(attrs["layout"], "default");
14558        assert_eq!(attrs["localId"], "aabbccdd-1234");
14559    }
14560
14561    #[test]
14562    fn bodied_extension_malformed_params_ignored() {
14563        // Malformed params JSON should be silently ignored, not crash
14564        let md = ":::extension{type=com.atlassian.macro key=details params='not-valid-json'}\nContent\n:::\n";
14565        let doc = markdown_to_adf(md).unwrap();
14566        let attrs = doc.content[0].attrs.as_ref().unwrap();
14567        assert_eq!(attrs["extensionKey"], "details");
14568        // parameters should be absent since the JSON was invalid
14569        assert!(attrs.get("parameters").is_none());
14570    }
14571
14572    #[test]
14573    fn leaf_extension_localid_preserved_in_roundtrip() {
14574        // Extension with both layout and localId
14575        let adf_json = r#"{"version":1,"type":"doc","content":[
14576          {"type":"extension","attrs":{"extensionType":"com.atlassian.macro","extensionKey":"toc","layout":"default","localId":"abc-123"}}
14577        ]}"#;
14578        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14579        let md = adf_to_markdown(&doc).unwrap();
14580        let round_tripped = markdown_to_adf(&md).unwrap();
14581        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14582        assert_eq!(attrs["layout"], "default");
14583        assert_eq!(attrs["localId"], "abc-123");
14584    }
14585
14586    // ── Mention with userType test ─────────────────────────────────
14587
14588    #[test]
14589    fn mention_with_user_type() {
14590        let md = "Hi :mention[Alice]{id=abc123 userType=DEFAULT}.\n";
14591        let doc = markdown_to_adf(md).unwrap();
14592        let mention = &doc.content[0].content.as_ref().unwrap()[1];
14593        assert_eq!(mention.attrs.as_ref().unwrap()["userType"], "DEFAULT");
14594
14595        let result = adf_to_markdown(&doc).unwrap();
14596        assert!(result.contains("userType=DEFAULT"));
14597    }
14598
14599    // ── Colwidth tests ─────────────────────────────────────────────
14600
14601    #[test]
14602    fn directive_table_colwidth() {
14603        let md = "::::table\n:::tr\n:::td{colwidth=100,200}\nCell\n:::\n:::\n::::\n";
14604        let doc = markdown_to_adf(md).unwrap();
14605        let cell = &doc.content[0].content.as_ref().unwrap()[0]
14606            .content
14607            .as_ref()
14608            .unwrap()[0];
14609        let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
14610        assert_eq!(colwidth, &[serde_json::json!(100), serde_json::json!(200)]);
14611    }
14612
14613    #[test]
14614    fn directive_table_colwidth_float_roundtrip() {
14615        // Confluence returns colwidth as floats (e.g. 157.0, 863.0).
14616        // adf_to_markdown must preserve them so markdown_to_adf can restore them.
14617        let adf_doc = serde_json::json!({
14618            "type": "doc",
14619            "version": 1,
14620            "content": [{
14621                "type": "table",
14622                "content": [{
14623                    "type": "tableRow",
14624                    "content": [
14625                        {
14626                            "type": "tableHeader",
14627                            "attrs": { "colwidth": [157.0] },
14628                            "content": [{ "type": "paragraph" }]
14629                        },
14630                        {
14631                            "type": "tableHeader",
14632                            "attrs": { "colwidth": [863.0] },
14633                            "content": [{ "type": "paragraph" }]
14634                        }
14635                    ]
14636                }]
14637            }]
14638        });
14639        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
14640        let md = adf_to_markdown(&doc).unwrap();
14641        assert!(
14642            md.contains("colwidth=157.0"),
14643            "expected colwidth=157.0 in markdown, got: {md}"
14644        );
14645        assert!(
14646            md.contains("colwidth=863.0"),
14647            "expected colwidth=863.0 in markdown, got: {md}"
14648        );
14649        // Round-trip back to ADF
14650        let doc2 = markdown_to_adf(&md).unwrap();
14651        let row = &doc2.content[0].content.as_ref().unwrap()[0];
14652        let header1 = &row.content.as_ref().unwrap()[0];
14653        let header2 = &row.content.as_ref().unwrap()[1];
14654        assert_eq!(
14655            header1.attrs.as_ref().unwrap()["colwidth"]
14656                .as_array()
14657                .unwrap(),
14658            &[serde_json::json!(157.0)]
14659        );
14660        assert_eq!(
14661            header2.attrs.as_ref().unwrap()["colwidth"]
14662                .as_array()
14663                .unwrap(),
14664            &[serde_json::json!(863.0)]
14665        );
14666    }
14667
14668    #[test]
14669    fn colwidth_float_preserved_in_roundtrip() {
14670        // Issue #369: colwidth 254.0 was coerced to integer 254
14671        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":[]}]}]}]}]}"#;
14672        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14673        let md = adf_to_markdown(&doc).unwrap();
14674        let round_tripped = markdown_to_adf(&md).unwrap();
14675        let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14676            .content
14677            .as_ref()
14678            .unwrap()[0];
14679        let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
14680        assert_eq!(
14681            colwidth,
14682            &[serde_json::json!(254.0), serde_json::json!(416.0)],
14683            "colwidth should preserve float values"
14684        );
14685    }
14686
14687    #[test]
14688    fn colwidth_integer_preserved_in_roundtrip() {
14689        // Issue #459: colwidth integer values emitted as floats after round-trip
14690        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"}]}]}]}]}]}"#;
14691        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14692        let md = adf_to_markdown(&doc).unwrap();
14693        assert!(
14694            md.contains("colwidth=150"),
14695            "expected colwidth=150 (no decimal) in markdown, got: {md}"
14696        );
14697        assert!(
14698            !md.contains("colwidth=150.0"),
14699            "colwidth should not have .0 suffix for integers, got: {md}"
14700        );
14701        // Round-trip back to ADF
14702        let round_tripped = markdown_to_adf(&md).unwrap();
14703        let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14704            .content
14705            .as_ref()
14706            .unwrap()[0];
14707        let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
14708        assert_eq!(
14709            colwidth,
14710            &[serde_json::json!(150)],
14711            "colwidth should preserve integer values"
14712        );
14713        // Verify JSON serialization uses integer, not float
14714        let json_output = serde_json::to_string(&round_tripped).unwrap();
14715        assert!(
14716            json_output.contains(r#""colwidth":[150]"#),
14717            "JSON should contain integer colwidth, got: {json_output}"
14718        );
14719    }
14720
14721    #[test]
14722    fn colwidth_mixed_int_and_float_roundtrip() {
14723        // Integer colwidth from standard ADF and float colwidth from Confluence
14724        // should each preserve their original type through round-trip.
14725        let int_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colwidth":[100,200]}}]}]}]}"#;
14726        let float_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colwidth":[100.0,200.0]}}]}]}]}"#;
14727
14728        // Integer input → integer output
14729        let int_doc: AdfDocument = serde_json::from_str(int_json).unwrap();
14730        let int_md = adf_to_markdown(&int_doc).unwrap();
14731        assert!(
14732            int_md.contains("colwidth=100,200"),
14733            "integer colwidth in md: {int_md}"
14734        );
14735        let int_rt = markdown_to_adf(&int_md).unwrap();
14736        let int_serial = serde_json::to_string(&int_rt).unwrap();
14737        assert!(
14738            int_serial.contains(r#""colwidth":[100,200]"#),
14739            "integer colwidth in JSON: {int_serial}"
14740        );
14741
14742        // Float input → float output
14743        let float_doc: AdfDocument = serde_json::from_str(float_json).unwrap();
14744        let float_md = adf_to_markdown(&float_doc).unwrap();
14745        assert!(
14746            float_md.contains("colwidth=100.0,200.0"),
14747            "float colwidth in md: {float_md}"
14748        );
14749        let float_rt = markdown_to_adf(&float_md).unwrap();
14750        let float_serial = serde_json::to_string(&float_rt).unwrap();
14751        assert!(
14752            float_serial.contains(r#""colwidth":[100.0,200.0]"#),
14753            "float colwidth in JSON: {float_serial}"
14754        );
14755    }
14756
14757    #[test]
14758    fn colwidth_fractional_float_preserved() {
14759        // Covers the fractional-float branch (n.fract() != 0.0) in build_cell_attrs_string
14760        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"}]}]}]}]}]}"#;
14761        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14762        let md = adf_to_markdown(&doc).unwrap();
14763        assert!(
14764            md.contains("colwidth=100.5"),
14765            "expected colwidth=100.5 in markdown, got: {md}"
14766        );
14767    }
14768
14769    #[test]
14770    fn colwidth_non_numeric_values_skipped() {
14771        // Covers the None branch for non-numeric colwidth entries in build_cell_attrs_string
14772        let adf_doc = serde_json::json!({
14773            "type": "doc",
14774            "version": 1,
14775            "content": [{
14776                "type": "table",
14777                "content": [{
14778                    "type": "tableRow",
14779                    "content": [{
14780                        "type": "tableCell",
14781                        "attrs": { "colwidth": ["invalid"] },
14782                        "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "cell" }] }]
14783                    }]
14784                }]
14785            }]
14786        });
14787        let doc: AdfDocument = serde_json::from_value(adf_doc).unwrap();
14788        let md = adf_to_markdown(&doc).unwrap();
14789        // Non-numeric values are filtered out, so colwidth should not appear
14790        assert!(
14791            !md.contains("colwidth"),
14792            "non-numeric colwidth should be filtered out, got: {md}"
14793        );
14794    }
14795
14796    #[test]
14797    fn default_rowspan_colspan_preserved_in_roundtrip() {
14798        // Issue #369: rowspan=1 and colspan=1 were elided
14799        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"}]}]}]}]}]}"#;
14800        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14801        let md = adf_to_markdown(&doc).unwrap();
14802        let round_tripped = markdown_to_adf(&md).unwrap();
14803        let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14804            .content
14805            .as_ref()
14806            .unwrap()[0];
14807        let attrs = cell.attrs.as_ref().unwrap();
14808        assert_eq!(attrs["rowspan"], 1, "rowspan=1 should be preserved");
14809        assert_eq!(attrs["colspan"], 1, "colspan=1 should be preserved");
14810    }
14811
14812    // ── Nested list tests ──────────────────────────────────────────────
14813
14814    #[test]
14815    fn table_localid_preserved_in_roundtrip() {
14816        // Issue #374: localId on table nodes was dropped
14817        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"}]}]}]}]}]}"#;
14818        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14819        let md = adf_to_markdown(&doc).unwrap();
14820        assert!(
14821            md.contains("localId="),
14822            "JFM should contain localId, got: {md}"
14823        );
14824        let round_tripped = markdown_to_adf(&md).unwrap();
14825        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14826        assert_eq!(
14827            attrs["localId"], "7afd4550-e66c-4b12-875f-a91c6c7b62c7",
14828            "localId should be preserved"
14829        );
14830    }
14831
14832    #[test]
14833    fn paragraph_localid_preserved_in_roundtrip() {
14834        // Issue #399: localId on paragraph nodes was dropped
14835        let adf_json = r#"{"version":1,"type":"doc","content":[
14836          {"type":"paragraph","attrs":{"localId":"abc-123"},"content":[{"type":"text","text":"hello"}]}
14837        ]}"#;
14838        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14839        let md = adf_to_markdown(&doc).unwrap();
14840        assert!(
14841            md.contains("localId=abc-123"),
14842            "JFM should contain localId, got: {md}"
14843        );
14844        let round_tripped = markdown_to_adf(&md).unwrap();
14845        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14846        assert_eq!(attrs["localId"], "abc-123", "localId should be preserved");
14847    }
14848
14849    #[test]
14850    fn heading_localid_preserved_in_roundtrip() {
14851        let adf_json = r#"{"version":1,"type":"doc","content":[
14852          {"type":"heading","attrs":{"level":2,"localId":"h-456"},"content":[{"type":"text","text":"Title"}]}
14853        ]}"#;
14854        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14855        let md = adf_to_markdown(&doc).unwrap();
14856        let round_tripped = markdown_to_adf(&md).unwrap();
14857        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14858        assert_eq!(attrs["localId"], "h-456");
14859    }
14860
14861    #[test]
14862    fn localid_with_alignment_preserved() {
14863        // localId and alignment marks should coexist in the same {attrs} block
14864        let adf_json = r#"{"version":1,"type":"doc","content":[
14865          {"type":"paragraph","attrs":{"localId":"p-789"},"marks":[{"type":"alignment","attrs":{"align":"center"}}],
14866           "content":[{"type":"text","text":"centered"}]}
14867        ]}"#;
14868        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14869        let md = adf_to_markdown(&doc).unwrap();
14870        assert!(md.contains("localId=p-789"), "should have localId: {md}");
14871        assert!(md.contains("align=center"), "should have align: {md}");
14872        let round_tripped = markdown_to_adf(&md).unwrap();
14873        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14874        assert_eq!(attrs["localId"], "p-789");
14875        let marks = round_tripped.content[0].marks.as_ref().unwrap();
14876        assert!(marks.iter().any(|m| m.mark_type == "alignment"));
14877    }
14878
14879    #[test]
14880    fn table_layout_default_preserved_in_roundtrip() {
14881        // Issue #380: layout='default' was elided
14882        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"}]}]}]}]}]}"#;
14883        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14884        let md = adf_to_markdown(&doc).unwrap();
14885        let round_tripped = markdown_to_adf(&md).unwrap();
14886        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14887        assert_eq!(
14888            attrs["layout"], "default",
14889            "layout='default' should be preserved"
14890        );
14891    }
14892
14893    #[test]
14894    fn table_is_number_column_enabled_false_preserved() {
14895        // Issue #380: isNumberColumnEnabled=false was elided
14896        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"}]}]}]}]}]}"#;
14897        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14898        let md = adf_to_markdown(&doc).unwrap();
14899        let round_tripped = markdown_to_adf(&md).unwrap();
14900        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14901        assert_eq!(
14902            attrs["isNumberColumnEnabled"], false,
14903            "isNumberColumnEnabled=false should be preserved"
14904        );
14905    }
14906
14907    #[test]
14908    fn table_is_number_column_enabled_true_preserved() {
14909        // Regression check: isNumberColumnEnabled=true should still work
14910        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"}]}]}]}]}]}"#;
14911        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14912        let md = adf_to_markdown(&doc).unwrap();
14913        let round_tripped = markdown_to_adf(&md).unwrap();
14914        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14915        assert_eq!(
14916            attrs["isNumberColumnEnabled"], true,
14917            "isNumberColumnEnabled=true should be preserved"
14918        );
14919    }
14920
14921    #[test]
14922    fn directive_table_is_number_column_enabled_false_preserved() {
14923        // Covers render_directive_table + directive table parsing for numbered=false.
14924        // Multi-paragraph cell forces directive table form.
14925        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[
14926          {"type":"paragraph","content":[{"type":"text","text":"line one"}]},
14927          {"type":"paragraph","content":[{"type":"text","text":"line two"}]}
14928        ]}]}]}]}"#;
14929        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14930        let md = adf_to_markdown(&doc).unwrap();
14931        assert!(md.contains("::::table"), "should use directive table form");
14932        assert!(
14933            md.contains("numbered=false"),
14934            "should contain numbered=false, got: {md}"
14935        );
14936        let round_tripped = markdown_to_adf(&md).unwrap();
14937        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14938        assert_eq!(attrs["isNumberColumnEnabled"], false);
14939        assert_eq!(attrs["layout"], "default");
14940    }
14941
14942    #[test]
14943    fn directive_table_is_number_column_enabled_true_preserved() {
14944        // Covers render_directive_table + directive table parsing for numbered (true).
14945        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":true,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[
14946          {"type":"paragraph","content":[{"type":"text","text":"line one"}]},
14947          {"type":"paragraph","content":[{"type":"text","text":"line two"}]}
14948        ]}]}]}]}"#;
14949        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14950        let md = adf_to_markdown(&doc).unwrap();
14951        assert!(md.contains("::::table"), "should use directive table form");
14952        assert!(
14953            md.contains("numbered}") || md.contains("numbered "),
14954            "should contain numbered flag, got: {md}"
14955        );
14956        let round_tripped = markdown_to_adf(&md).unwrap();
14957        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14958        assert_eq!(attrs["isNumberColumnEnabled"], true);
14959    }
14960
14961    #[test]
14962    fn trailing_space_in_bullet_list_item_preserved() {
14963        // Issue #394: trailing space text node in list item dropped
14964        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
14965          {"type":"listItem","content":[{"type":"paragraph","content":[
14966            {"type":"text","text":"Before link "},
14967            {"type":"text","text":"link text","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
14968            {"type":"text","text":" "}
14969          ]}]}
14970        ]}]}"#;
14971        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14972        let md = adf_to_markdown(&doc).unwrap();
14973        let round_tripped = markdown_to_adf(&md).unwrap();
14974        let list = &round_tripped.content[0];
14975        let item = &list.content.as_ref().unwrap()[0];
14976        let para = &item.content.as_ref().unwrap()[0];
14977        let inlines = para.content.as_ref().unwrap();
14978        let last = inlines.last().unwrap();
14979        assert_eq!(
14980            last.text.as_deref(),
14981            Some(" "),
14982            "trailing space text node should be preserved, got nodes: {:?}",
14983            inlines
14984                .iter()
14985                .map(|n| (&n.node_type, &n.text))
14986                .collect::<Vec<_>>()
14987        );
14988    }
14989
14990    #[test]
14991    fn trailing_space_after_mention_in_bullet_list_preserved() {
14992        // Mention + trailing space in list item
14993        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
14994          {"type":"listItem","content":[{"type":"paragraph","content":[
14995            {"type":"mention","attrs":{"id":"abc","text":"@Alice"}},
14996            {"type":"text","text":" "}
14997          ]}]}
14998        ]}]}"#;
14999        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15000        let md = adf_to_markdown(&doc).unwrap();
15001        let round_tripped = markdown_to_adf(&md).unwrap();
15002        let para = &round_tripped.content[0].content.as_ref().unwrap()[0]
15003            .content
15004            .as_ref()
15005            .unwrap()[0];
15006        let inlines = para.content.as_ref().unwrap();
15007        assert!(
15008            inlines.len() >= 2,
15009            "should have mention + trailing space, got {} nodes",
15010            inlines.len()
15011        );
15012        assert_eq!(inlines.last().unwrap().text.as_deref(), Some(" "));
15013    }
15014
15015    #[test]
15016    fn trailing_space_in_ordered_list_item_preserved() {
15017        // Same issue in ordered list context
15018        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
15019          {"type":"listItem","content":[{"type":"paragraph","content":[
15020            {"type":"text","text":"item "},
15021            {"type":"text","text":"link","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
15022            {"type":"text","text":" "}
15023          ]}]}
15024        ]}]}"#;
15025        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15026        let md = adf_to_markdown(&doc).unwrap();
15027        let round_tripped = markdown_to_adf(&md).unwrap();
15028        let para = &round_tripped.content[0].content.as_ref().unwrap()[0]
15029            .content
15030            .as_ref()
15031            .unwrap()[0];
15032        let inlines = para.content.as_ref().unwrap();
15033        let last = inlines.last().unwrap();
15034        assert_eq!(
15035            last.text.as_deref(),
15036            Some(" "),
15037            "trailing space should be preserved in ordered list item"
15038        );
15039    }
15040
15041    #[test]
15042    fn trailing_space_in_heading_text_preserved() {
15043        // Issue #400: trailing space in heading text node trimmed on round-trip
15044        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[
15045          {"type":"text","text":"Firefighting Engineers "}
15046        ]}]}"#;
15047        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15048        let md = adf_to_markdown(&doc).unwrap();
15049        let round_tripped = markdown_to_adf(&md).unwrap();
15050        let inlines = round_tripped.content[0].content.as_ref().unwrap();
15051        assert_eq!(
15052            inlines[0].text.as_deref(),
15053            Some("Firefighting Engineers "),
15054            "trailing space in heading should be preserved"
15055        );
15056    }
15057
15058    #[test]
15059    fn trailing_space_in_heading_before_bold_preserved() {
15060        // Issue #400: trailing space before bold sibling in heading
15061        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[
15062          {"type":"text","text":"Classic "},
15063          {"type":"text","text":"bold","marks":[{"type":"strong"}]}
15064        ]}]}"#;
15065        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15066        let md = adf_to_markdown(&doc).unwrap();
15067        let round_tripped = markdown_to_adf(&md).unwrap();
15068        let inlines = round_tripped.content[0].content.as_ref().unwrap();
15069        assert_eq!(
15070            inlines[0].text.as_deref(),
15071            Some("Classic "),
15072            "trailing space in heading text before bold should be preserved"
15073        );
15074    }
15075
15076    #[test]
15077    fn leading_space_in_heading_text_preserved() {
15078        // Issue #492: leading spaces in heading text node stripped on round-trip
15079        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":3},"content":[
15080          {"type":"text","text":"  #general-channel"}
15081        ]}]}"#;
15082        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15083        let md = adf_to_markdown(&doc).unwrap();
15084        let round_tripped = markdown_to_adf(&md).unwrap();
15085        let inlines = round_tripped.content[0].content.as_ref().unwrap();
15086        assert_eq!(
15087            inlines[0].text.as_deref(),
15088            Some("  #general-channel"),
15089            "leading spaces in heading text should be preserved"
15090        );
15091    }
15092
15093    #[test]
15094    fn leading_space_in_heading_before_bold_preserved() {
15095        // Issue #492: leading space before bold sibling in heading
15096        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[
15097          {"type":"text","text":"   indented"},
15098          {"type":"text","text":" bold","marks":[{"type":"strong"}]}
15099        ]}]}"#;
15100        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15101        let md = adf_to_markdown(&doc).unwrap();
15102        let round_tripped = markdown_to_adf(&md).unwrap();
15103        let inlines = round_tripped.content[0].content.as_ref().unwrap();
15104        assert_eq!(
15105            inlines[0].text.as_deref(),
15106            Some("   indented"),
15107            "leading spaces in heading text before bold should be preserved"
15108        );
15109    }
15110
15111    #[test]
15112    fn heading_multiple_leading_spaces_markdown_parse() {
15113        // Issue #492: verify JFM parsing preserves leading spaces
15114        let md = "### \t  #general-channel";
15115        let doc = markdown_to_adf(md).unwrap();
15116        let inlines = doc.content[0].content.as_ref().unwrap();
15117        assert_eq!(
15118            inlines[0].text.as_deref(),
15119            Some("\t  #general-channel"),
15120            "leading whitespace in heading text should be preserved during JFM parsing"
15121        );
15122    }
15123
15124    #[test]
15125    fn trailing_space_in_paragraph_text_preserved() {
15126        // Issue #400: trailing space in paragraph text node preserved on round-trip
15127        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
15128          {"type":"text","text":"word followed by space "},
15129          {"type":"text","text":"next node","marks":[{"type":"strong"}]}
15130        ]}]}"#;
15131        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15132        let md = adf_to_markdown(&doc).unwrap();
15133        let round_tripped = markdown_to_adf(&md).unwrap();
15134        let inlines = round_tripped.content[0].content.as_ref().unwrap();
15135        assert_eq!(
15136            inlines[0].text.as_deref(),
15137            Some("word followed by space "),
15138            "trailing space in paragraph text should be preserved"
15139        );
15140    }
15141
15142    #[test]
15143    fn nested_bullet_list_roundtrip() {
15144        // ADF with a listItem containing a paragraph + nested bulletList
15145        let adf_doc = serde_json::json!({
15146            "type": "doc",
15147            "version": 1,
15148            "content": [{
15149                "type": "bulletList",
15150                "content": [{
15151                    "type": "listItem",
15152                    "content": [
15153                        {
15154                            "type": "paragraph",
15155                            "content": [{"type": "text", "text": "parent item"}]
15156                        },
15157                        {
15158                            "type": "bulletList",
15159                            "content": [
15160                                {
15161                                    "type": "listItem",
15162                                    "content": [{
15163                                        "type": "paragraph",
15164                                        "content": [{"type": "text", "text": "sub item 1"}]
15165                                    }]
15166                                },
15167                                {
15168                                    "type": "listItem",
15169                                    "content": [{
15170                                        "type": "paragraph",
15171                                        "content": [{"type": "text", "text": "sub item 2"}]
15172                                    }]
15173                                }
15174                            ]
15175                        }
15176                    ]
15177                }]
15178            }]
15179        });
15180        let doc: AdfDocument = serde_json::from_value(adf_doc).unwrap();
15181        let md = adf_to_markdown(&doc).unwrap();
15182        assert!(
15183            md.contains("- parent item\n"),
15184            "expected top-level item in markdown, got: {md}"
15185        );
15186        assert!(
15187            md.contains("  - sub item 1\n"),
15188            "expected indented sub item 1 in markdown, got: {md}"
15189        );
15190        assert!(
15191            md.contains("  - sub item 2\n"),
15192            "expected indented sub item 2 in markdown, got: {md}"
15193        );
15194
15195        // Round-trip back
15196        let doc2 = markdown_to_adf(&md).unwrap();
15197        let list = &doc2.content[0];
15198        assert_eq!(list.node_type, "bulletList");
15199        let item = &list.content.as_ref().unwrap()[0];
15200        assert_eq!(item.node_type, "listItem");
15201        let item_content = item.content.as_ref().unwrap();
15202        assert_eq!(
15203            item_content.len(),
15204            2,
15205            "listItem should have paragraph + nested list"
15206        );
15207        assert_eq!(item_content[0].node_type, "paragraph");
15208        assert_eq!(item_content[1].node_type, "bulletList");
15209        let sub_items = item_content[1].content.as_ref().unwrap();
15210        assert_eq!(sub_items.len(), 2);
15211    }
15212
15213    #[test]
15214    fn nested_bullet_in_table_cell_roundtrip() {
15215        let md = "::::table\n:::tr\n:::td\n- parent\n  - child\n:::\n:::\n::::\n";
15216        let doc = markdown_to_adf(md).unwrap();
15217        let table = &doc.content[0];
15218        let row = &table.content.as_ref().unwrap()[0];
15219        let cell = &row.content.as_ref().unwrap()[0];
15220        let list = &cell.content.as_ref().unwrap()[0];
15221        assert_eq!(list.node_type, "bulletList");
15222        let item = &list.content.as_ref().unwrap()[0];
15223        let item_content = item.content.as_ref().unwrap();
15224        assert_eq!(
15225            item_content.len(),
15226            2,
15227            "listItem should have paragraph + nested list"
15228        );
15229        assert_eq!(item_content[1].node_type, "bulletList");
15230
15231        // Round-trip: adf→md→adf should preserve the nested list
15232        let md2 = adf_to_markdown(&doc).unwrap();
15233        assert!(
15234            md2.contains("  - child"),
15235            "expected indented child in round-tripped markdown, got: {md2}"
15236        );
15237    }
15238
15239    #[test]
15240    fn nested_ordered_list_roundtrip() {
15241        // Issue #389: nested orderedList inside listItem flattened
15242        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
15243          {"type":"listItem","content":[
15244            {"type":"paragraph","content":[{"type":"text","text":"Top level"}]},
15245            {"type":"orderedList","attrs":{"order":1},"content":[
15246              {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Nested 1"}]}]},
15247              {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Nested 2"}]}]}
15248            ]}
15249          ]},
15250          {"type":"listItem","content":[
15251            {"type":"paragraph","content":[{"type":"text","text":"Second top"}]}
15252          ]}
15253        ]}]}"#;
15254        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15255        let md = adf_to_markdown(&doc).unwrap();
15256        let round_tripped = markdown_to_adf(&md).unwrap();
15257
15258        // Outer list should have 2 items
15259        let outer = &round_tripped.content[0];
15260        assert_eq!(outer.node_type, "orderedList");
15261        assert_eq!(
15262            outer.attrs.as_ref().unwrap()["order"],
15263            1,
15264            "explicit order=1 must be preserved via trailing {{order=1}} (issue #547)"
15265        );
15266        let outer_items = outer.content.as_ref().unwrap();
15267        assert_eq!(
15268            outer_items.len(),
15269            2,
15270            "outer list should have 2 items, got {}",
15271            outer_items.len()
15272        );
15273
15274        // First item should have paragraph + nested orderedList
15275        let first_item = &outer_items[0];
15276        let first_content = first_item.content.as_ref().unwrap();
15277        assert_eq!(
15278            first_content.len(),
15279            2,
15280            "first listItem should have paragraph + nested list, got {}",
15281            first_content.len()
15282        );
15283        assert_eq!(first_content[0].node_type, "paragraph");
15284        assert_eq!(first_content[1].node_type, "orderedList");
15285        let nested_items = first_content[1].content.as_ref().unwrap();
15286        assert_eq!(nested_items.len(), 2, "nested list should have 2 items");
15287    }
15288
15289    #[test]
15290    fn nested_ordered_list_markdown_parsing() {
15291        // Direct markdown parsing of nested ordered list
15292        let md = "1. Top level\n  1. Nested 1\n  2. Nested 2\n2. Second top\n";
15293        let doc = markdown_to_adf(md).unwrap();
15294        let outer = &doc.content[0];
15295        assert_eq!(outer.node_type, "orderedList");
15296        let outer_items = outer.content.as_ref().unwrap();
15297        assert_eq!(outer_items.len(), 2, "should have 2 top-level items");
15298
15299        let first_content = outer_items[0].content.as_ref().unwrap();
15300        assert_eq!(
15301            first_content.len(),
15302            2,
15303            "first item should have paragraph + nested list"
15304        );
15305        assert_eq!(first_content[1].node_type, "orderedList");
15306    }
15307
15308    #[test]
15309    fn bullet_list_nested_inside_ordered_list() {
15310        // Mixed nesting: bullet list nested inside ordered list
15311        let md = "1. Ordered item\n  - Bullet child 1\n  - Bullet child 2\n2. Second ordered\n";
15312        let doc = markdown_to_adf(md).unwrap();
15313        let outer = &doc.content[0];
15314        assert_eq!(outer.node_type, "orderedList");
15315        let outer_items = outer.content.as_ref().unwrap();
15316        assert_eq!(outer_items.len(), 2);
15317
15318        let first_content = outer_items[0].content.as_ref().unwrap();
15319        assert_eq!(
15320            first_content.len(),
15321            2,
15322            "first item should have paragraph + nested list"
15323        );
15324        assert_eq!(first_content[1].node_type, "bulletList");
15325        let sub_items = first_content[1].content.as_ref().unwrap();
15326        assert_eq!(sub_items.len(), 2, "nested bullet list should have 2 items");
15327    }
15328
15329    #[test]
15330    fn ordered_list_order_attr_one_is_elided() {
15331        // Issue #547: order=1 is the default and must be elided from attrs
15332        // for round-trip fidelity with ADF documents that omit the attrs
15333        // object on orderedList.
15334        let md = "1. A\n2. B\n";
15335        let doc = markdown_to_adf(md).unwrap();
15336        assert!(
15337            doc.content[0].attrs.is_none(),
15338            "attrs should be elided when order=1"
15339        );
15340
15341        // Round-trip should preserve the elision
15342        let md2 = adf_to_markdown(&doc).unwrap();
15343        let doc2 = markdown_to_adf(&md2).unwrap();
15344        assert!(
15345            doc2.content[0].attrs.is_none(),
15346            "attrs should remain elided after round-trip"
15347        );
15348    }
15349
15350    #[test]
15351    fn issue_547_ordered_list_no_attrs_roundtrip_byte_identical() {
15352        // Issue #547: ADF orderedList without an attrs field must round-trip
15353        // (ADF → JFM → ADF) without gaining a spurious {"order": 1} attrs.
15354        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"}]}]}]}]}"#;
15355        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15356        let md = adf_to_markdown(&doc).unwrap();
15357        let rt = markdown_to_adf(&md).unwrap();
15358        assert!(
15359            rt.content[0].attrs.is_none(),
15360            "round-tripped orderedList should not have attrs, got: {:?}",
15361            rt.content[0].attrs
15362        );
15363
15364        // Serialized JSON must also omit attrs entirely for byte fidelity.
15365        let rt_json = serde_json::to_string(&rt).unwrap();
15366        assert!(
15367            !rt_json.contains("\"order\""),
15368            "round-tripped JSON should not contain \"order\", got: {rt_json}"
15369        );
15370    }
15371
15372    // ── Issue #547: orderedList byte-identical roundtrip coverage ───────
15373
15374    /// Assert that ADF → JFM → ADF produces a document whose serialized JSON
15375    /// (as a sorted-key canonical form) matches the source JSON. Mirrors the
15376    /// `jq --sort-keys` comparison used in the issue's reproducer.
15377    fn assert_roundtrip_byte_identical(adf_json: &str) {
15378        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15379        let md = adf_to_markdown(&doc).unwrap();
15380        let rt = markdown_to_adf(&md).unwrap();
15381
15382        let canonical_src: serde_json::Value = serde_json::from_str(adf_json).unwrap();
15383        let canonical_rt: serde_json::Value =
15384            serde_json::from_str(&serde_json::to_string(&rt).unwrap()).unwrap();
15385        assert_eq!(
15386            canonical_src, canonical_rt,
15387            "round-trip diverged\n  src: {canonical_src}\n   rt: {canonical_rt}\n   md: {md:?}"
15388        );
15389    }
15390
15391    #[test]
15392    fn issue_547_single_item_no_attrs_roundtrip() {
15393        assert_roundtrip_byte_identical(
15394            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"only"}]}]}]}]}"#,
15395        );
15396    }
15397
15398    #[test]
15399    fn issue_547_many_items_no_attrs_roundtrip() {
15400        assert_roundtrip_byte_identical(
15401            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"}]}]}]}]}"#,
15402        );
15403    }
15404
15405    #[test]
15406    fn issue_547_non_default_order_preserved() {
15407        // When order != 1, attrs must still be serialized (fix must not
15408        // over-eagerly drop attrs).
15409        assert_roundtrip_byte_identical(
15410            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":5},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"fifth"}]}]}]}]}"#,
15411        );
15412    }
15413
15414    #[test]
15415    fn issue_547_nested_ordered_in_ordered_no_attrs_roundtrip() {
15416        // Outer and inner both omit attrs; fix must apply at every level.
15417        assert_roundtrip_byte_identical(
15418            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"}]}]}]}]}]}]}"#,
15419        );
15420    }
15421
15422    #[test]
15423    fn issue_547_ordered_nested_in_bullet_no_attrs_roundtrip() {
15424        assert_roundtrip_byte_identical(
15425            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"}]}]}]}]}]}]}"#,
15426        );
15427    }
15428
15429    #[test]
15430    fn issue_547_bullet_nested_in_ordered_no_attrs_roundtrip() {
15431        assert_roundtrip_byte_identical(
15432            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"}]}]}]}]}]}]}"#,
15433        );
15434    }
15435
15436    #[test]
15437    fn issue_547_ordered_list_between_paragraphs_roundtrip() {
15438        assert_roundtrip_byte_identical(
15439            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"}]}]}"#,
15440        );
15441    }
15442
15443    #[test]
15444    fn issue_547_ordered_list_with_marked_text_roundtrip() {
15445        assert_roundtrip_byte_identical(
15446            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"bold","marks":[{"type":"strong"}]}]}]}]}]}"#,
15447        );
15448    }
15449
15450    #[test]
15451    fn issue_547_ordered_list_with_link_roundtrip() {
15452        assert_roundtrip_byte_identical(
15453            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"}}]}]}]}]}]}"#,
15454        );
15455    }
15456
15457    #[test]
15458    fn issue_547_ordered_list_with_hardbreak_roundtrip() {
15459        assert_roundtrip_byte_identical(
15460            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"}]}]}]}]}"#,
15461        );
15462    }
15463
15464    #[test]
15465    fn issue_547_triple_nested_ordered_roundtrip() {
15466        assert_roundtrip_byte_identical(
15467            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"}]}]}]}]}]}]}]}]}"#,
15468        );
15469    }
15470
15471    #[test]
15472    fn issue_547_ordered_list_heading_rule_mix_roundtrip() {
15473        assert_roundtrip_byte_identical(
15474            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"}]}"#,
15475        );
15476    }
15477
15478    #[test]
15479    fn issue_547_ordered_list_listitem_localid_roundtrip() {
15480        // listItem attrs must coexist with the no-attrs outer orderedList.
15481        assert_roundtrip_byte_identical(
15482            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","attrs":{"localId":"li-001"},"content":[{"type":"paragraph","content":[{"type":"text","text":"first"}]}]}]}]}"#,
15483        );
15484    }
15485
15486    #[test]
15487    fn issue_547_explicit_order_one_preserved_roundtrip() {
15488        // Inverse regression (see PR #562 comment 4266630848): when the source
15489        // ADF has an explicit `"attrs": {"order": 1}` the round-trip must
15490        // preserve it, not strip it. A trailing `{order=1}` signal on the
15491        // rendered markdown distinguishes explicit-default from omitted attrs.
15492        assert_roundtrip_byte_identical(
15493            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"First item"}]}]}]}]}"#,
15494        );
15495    }
15496
15497    #[test]
15498    fn issue_547_explicit_order_one_nested_preserved_roundtrip() {
15499        // Both outer and inner orderedList have explicit `order: 1`; both must
15500        // be preserved across the round-trip independently.
15501        assert_roundtrip_byte_identical(
15502            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"}]}]}]}]}]}]}"#,
15503        );
15504    }
15505
15506    #[test]
15507    fn issue_547_mixed_explicit_and_implicit_order_roundtrip() {
15508        // Sibling orderedLists with different attrs presence must round-trip
15509        // independently: first has explicit `order: 1`, second omits attrs.
15510        assert_roundtrip_byte_identical(
15511            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"}]}]}]}]}"#,
15512        );
15513    }
15514
15515    #[test]
15516    fn issue_547_explicit_order_one_with_listitem_localid_roundtrip() {
15517        // Explicit `order: 1` outer, plus a listItem `localId` inside — the
15518        // trailing `{order=1}` line must not swallow or collide with listItem
15519        // attrs.
15520        assert_roundtrip_byte_identical(
15521            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"}]}]}]}]}"#,
15522        );
15523    }
15524
15525    #[test]
15526    fn issue_547_order_attr_signal_appears_only_for_explicit_one() {
15527        // Render-layer guard: `{order=1}` appears in markdown only when the
15528        // source ADF has explicit `attrs.order=1`. No signal for attrs=None,
15529        // no signal for attrs.order>1 (marker already encodes the value).
15530        let no_attrs = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]}]}"#;
15531        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"}]}]}]}]}"#;
15532        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"}]}]}]}]}"#;
15533
15534        let md_no =
15535            adf_to_markdown(&serde_json::from_str::<AdfDocument>(no_attrs).unwrap()).unwrap();
15536        let md_one =
15537            adf_to_markdown(&serde_json::from_str::<AdfDocument>(explicit_one).unwrap()).unwrap();
15538        let md_five =
15539            adf_to_markdown(&serde_json::from_str::<AdfDocument>(order_five).unwrap()).unwrap();
15540
15541        assert!(
15542            !md_no.contains("{order="),
15543            "no-attrs source must not emit order signal, got: {md_no:?}"
15544        );
15545        assert!(
15546            md_one.contains("{order=1}"),
15547            "explicit order=1 must emit trailing signal, got: {md_one:?}"
15548        );
15549        assert!(
15550            !md_five.contains("{order="),
15551            "order=5 is already encoded by marker; must not emit signal, got: {md_five:?}"
15552        );
15553    }
15554
15555    // ── File media round-trip tests ─────────────────────────────────────
15556
15557    #[test]
15558    fn file_media_roundtrip() {
15559        // ADF with a Confluence file attachment (type:file media)
15560        let adf_doc = serde_json::json!({
15561            "type": "doc",
15562            "version": 1,
15563            "content": [{
15564                "type": "mediaSingle",
15565                "attrs": {"layout": "center"},
15566                "content": [{
15567                    "type": "media",
15568                    "attrs": {
15569                        "type": "file",
15570                        "id": "6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d",
15571                        "collection": "contentId-8220672100",
15572                        "height": 56,
15573                        "width": 312,
15574                        "alt": "Screenshot.png"
15575                    }
15576                }]
15577            }]
15578        });
15579        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
15580        let md = adf_to_markdown(&doc).unwrap();
15581        assert!(
15582            md.contains("type=file"),
15583            "expected type=file in markdown, got: {md}"
15584        );
15585        assert!(
15586            md.contains("id=6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d"),
15587            "expected id in markdown, got: {md}"
15588        );
15589        assert!(
15590            md.contains("collection=contentId-8220672100"),
15591            "expected collection in markdown, got: {md}"
15592        );
15593        // Round-trip back to ADF
15594        let doc2 = markdown_to_adf(&md).unwrap();
15595        let ms = &doc2.content[0];
15596        assert_eq!(ms.node_type, "mediaSingle");
15597        let media = &ms.content.as_ref().unwrap()[0];
15598        assert_eq!(media.node_type, "media");
15599        let attrs = media.attrs.as_ref().unwrap();
15600        assert_eq!(attrs["type"], "file");
15601        assert_eq!(attrs["id"], "6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d");
15602        assert_eq!(attrs["collection"], "contentId-8220672100");
15603        assert_eq!(attrs["height"], 56);
15604        assert_eq!(attrs["width"], 312);
15605        assert_eq!(attrs["alt"], "Screenshot.png");
15606    }
15607
15608    /// Issue #550: roundtrip of mediaSingle with file-type media preserves all
15609    /// file attributes (type, id, collection, width, height). Regression guard
15610    /// for the exact reproducer in the issue body.
15611    #[test]
15612    fn file_media_roundtrip_issue_550_reproducer() {
15613        let adf_json = r#"{
15614          "version": 1,
15615          "type": "doc",
15616          "content": [
15617            {
15618              "type": "mediaSingle",
15619              "attrs": {"layout": "center"},
15620              "content": [
15621                {
15622                  "type": "media",
15623                  "attrs": {
15624                    "type": "file",
15625                    "id": "abc-123-def-456",
15626                    "collection": "my-collection",
15627                    "width": 941,
15628                    "height": 655
15629                  }
15630                }
15631              ]
15632            }
15633          ]
15634        }"#;
15635        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15636        let md = adf_to_markdown(&doc).unwrap();
15637        let rt = markdown_to_adf(&md).unwrap();
15638        let expected: serde_json::Value = serde_json::from_str(adf_json).unwrap();
15639        let actual = serde_json::to_value(&rt).unwrap();
15640        assert_eq!(
15641            actual, expected,
15642            "roundtrip should preserve file media attrs; md was:\n{md}"
15643        );
15644    }
15645
15646    /// Issue #550 (updated reproducer): roundtrip of a file-media `id`
15647    /// containing spaces must not truncate the value. Before the fix, the
15648    /// JFM renderer emitted `id=abc 123 def 456` unquoted and the parser
15649    /// treated the first space as a value terminator, so the `id` became
15650    /// `"abc"` after round-trip.
15651    #[test]
15652    fn file_media_roundtrip_id_with_spaces() {
15653        let adf_json = r#"{
15654          "version": 1,
15655          "type": "doc",
15656          "content": [
15657            {
15658              "type": "mediaSingle",
15659              "attrs": {"layout": "center"},
15660              "content": [
15661                {
15662                  "type": "media",
15663                  "attrs": {
15664                    "type": "file",
15665                    "id": "abc 123 def 456",
15666                    "collection": "my-collection",
15667                    "width": 800,
15668                    "height": 600
15669                  }
15670                }
15671              ]
15672            }
15673          ]
15674        }"#;
15675        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15676        let md = adf_to_markdown(&doc).unwrap();
15677        assert!(
15678            md.contains(r#"id="abc 123 def 456""#),
15679            "id with spaces should be quoted in JFM, got:\n{md}"
15680        );
15681        let rt = markdown_to_adf(&md).unwrap();
15682        let expected: serde_json::Value = serde_json::from_str(adf_json).unwrap();
15683        let actual = serde_json::to_value(&rt).unwrap();
15684        assert_eq!(
15685            actual, expected,
15686            "space-containing id must round-trip; md was:\n{md}"
15687        );
15688    }
15689
15690    /// Space-containing `collection` values must round-trip.
15691    #[test]
15692    fn file_media_roundtrip_collection_with_spaces() {
15693        let adf_json = r#"{
15694          "version": 1,
15695          "type": "doc",
15696          "content": [
15697            {
15698              "type": "mediaSingle",
15699              "attrs": {"layout": "center"},
15700              "content": [
15701                {
15702                  "type": "media",
15703                  "attrs": {
15704                    "type": "file",
15705                    "id": "abc-123",
15706                    "collection": "my collection with spaces"
15707                  }
15708                }
15709              ]
15710            }
15711          ]
15712        }"#;
15713        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15714        let md = adf_to_markdown(&doc).unwrap();
15715        let rt = markdown_to_adf(&md).unwrap();
15716        let media = &rt.content[0].content.as_ref().unwrap()[0];
15717        assert_eq!(
15718            media.attrs.as_ref().unwrap()["collection"],
15719            "my collection with spaces"
15720        );
15721    }
15722
15723    /// Space-containing `occurrenceKey` values must round-trip.
15724    #[test]
15725    fn file_media_roundtrip_occurrence_key_with_spaces() {
15726        let adf_json = r#"{
15727          "version": 1,
15728          "type": "doc",
15729          "content": [
15730            {
15731              "type": "mediaSingle",
15732              "attrs": {"layout": "center"},
15733              "content": [
15734                {
15735                  "type": "media",
15736                  "attrs": {
15737                    "type": "file",
15738                    "id": "x",
15739                    "collection": "y",
15740                    "occurrenceKey": "key with spaces"
15741                  }
15742                }
15743              ]
15744            }
15745          ]
15746        }"#;
15747        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15748        let md = adf_to_markdown(&doc).unwrap();
15749        let rt = markdown_to_adf(&md).unwrap();
15750        let media = &rt.content[0].content.as_ref().unwrap()[0];
15751        assert_eq!(
15752            media.attrs.as_ref().unwrap()["occurrenceKey"],
15753            "key with spaces"
15754        );
15755    }
15756
15757    /// Values with embedded `"` must be escape-quoted and round-trip.
15758    #[test]
15759    fn file_media_roundtrip_id_with_quote_char() {
15760        let adf_json = r#"{
15761          "version": 1,
15762          "type": "doc",
15763          "content": [
15764            {
15765              "type": "mediaSingle",
15766              "attrs": {"layout": "center"},
15767              "content": [
15768                {
15769                  "type": "media",
15770                  "attrs": {
15771                    "type": "file",
15772                    "id": "a\"b\"c",
15773                    "collection": "col"
15774                  }
15775                }
15776              ]
15777            }
15778          ]
15779        }"#;
15780        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15781        let md = adf_to_markdown(&doc).unwrap();
15782        let rt = markdown_to_adf(&md).unwrap();
15783        let media = &rt.content[0].content.as_ref().unwrap()[0];
15784        assert_eq!(media.attrs.as_ref().unwrap()["id"], "a\"b\"c");
15785    }
15786
15787    /// `mediaInline` string attrs with spaces must round-trip (parallel fix
15788    /// for the inline-directive rendering path).
15789    #[test]
15790    fn media_inline_roundtrip_id_with_spaces() {
15791        let adf_json = r#"{
15792          "version": 1,
15793          "type": "doc",
15794          "content": [
15795            {
15796              "type": "paragraph",
15797              "content": [
15798                {"type": "text", "text": "before "},
15799                {
15800                  "type": "mediaInline",
15801                  "attrs": {
15802                    "type": "file",
15803                    "id": "a b c",
15804                    "collection": "my col"
15805                  }
15806                },
15807                {"type": "text", "text": " after"}
15808              ]
15809            }
15810          ]
15811        }"#;
15812        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15813        let md = adf_to_markdown(&doc).unwrap();
15814        let rt = markdown_to_adf(&md).unwrap();
15815        let inline = &rt.content[0].content.as_ref().unwrap()[1];
15816        assert_eq!(inline.node_type, "mediaInline");
15817        let attrs = inline.attrs.as_ref().unwrap();
15818        assert_eq!(attrs["id"], "a b c");
15819        assert_eq!(attrs["collection"], "my col");
15820    }
15821
15822    /// Issue #550: `occurrenceKey` attribute is a standard ADF media attr and
15823    /// must be preserved through ADF→JFM→ADF roundtrip.
15824    #[test]
15825    fn file_media_roundtrip_preserves_occurrence_key() {
15826        let adf_json = r#"{
15827          "version": 1,
15828          "type": "doc",
15829          "content": [
15830            {
15831              "type": "mediaSingle",
15832              "attrs": {"layout": "center"},
15833              "content": [
15834                {
15835                  "type": "media",
15836                  "attrs": {
15837                    "type": "file",
15838                    "id": "abc-123",
15839                    "collection": "my-collection",
15840                    "occurrenceKey": "unique-key-xyz",
15841                    "width": 200,
15842                    "height": 100
15843                  }
15844                }
15845              ]
15846            }
15847          ]
15848        }"#;
15849        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15850        let md = adf_to_markdown(&doc).unwrap();
15851        assert!(
15852            md.contains("occurrenceKey=unique-key-xyz"),
15853            "expected occurrenceKey in markdown, got: {md}"
15854        );
15855        let rt = markdown_to_adf(&md).unwrap();
15856        let media = &rt.content[0].content.as_ref().unwrap()[0];
15857        let attrs = media.attrs.as_ref().unwrap();
15858        assert_eq!(attrs["occurrenceKey"], "unique-key-xyz");
15859        assert_eq!(attrs["type"], "file");
15860        assert_eq!(attrs["id"], "abc-123");
15861        assert_eq!(attrs["collection"], "my-collection");
15862    }
15863
15864    // ── mediaSingle caption tests (issue #470) ──────────────────────────
15865
15866    #[test]
15867    fn media_single_caption_adf_to_markdown() {
15868        let adf_doc = serde_json::json!({
15869            "type": "doc",
15870            "version": 1,
15871            "content": [{
15872                "type": "mediaSingle",
15873                "attrs": {"layout": "center", "width": 400, "widthType": "pixel"},
15874                "content": [
15875                    {
15876                        "type": "media",
15877                        "attrs": {
15878                            "id": "aabbccdd-1234-5678-abcd-aabbccdd1234",
15879                            "type": "file",
15880                            "collection": "contentId-123456",
15881                            "width": 800,
15882                            "height": 600
15883                        }
15884                    },
15885                    {
15886                        "type": "caption",
15887                        "content": [{"type": "text", "text": "An image caption here"}]
15888                    }
15889                ]
15890            }]
15891        });
15892        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
15893        let md = adf_to_markdown(&doc).unwrap();
15894        assert!(
15895            md.contains(":::caption"),
15896            "expected :::caption in markdown, got: {md}"
15897        );
15898        assert!(
15899            md.contains("An image caption here"),
15900            "expected caption text in markdown, got: {md}"
15901        );
15902    }
15903
15904    #[test]
15905    fn media_single_caption_markdown_to_adf() {
15906        let md = "![Screenshot](){type=file id=abc-123 collection=contentId-456 height=600 width=800}\n:::caption\nAn image caption here\n:::\n";
15907        let doc = markdown_to_adf(md).unwrap();
15908        let ms = &doc.content[0];
15909        assert_eq!(ms.node_type, "mediaSingle");
15910        let content = ms.content.as_ref().unwrap();
15911        assert_eq!(content.len(), 2, "expected media + caption children");
15912        assert_eq!(content[0].node_type, "media");
15913        assert_eq!(content[1].node_type, "caption");
15914        let caption_content = content[1].content.as_ref().unwrap();
15915        assert_eq!(
15916            caption_content[0].text.as_deref(),
15917            Some("An image caption here")
15918        );
15919    }
15920
15921    #[test]
15922    fn media_single_caption_round_trip() {
15923        // Full round-trip: ADF → JFM → ADF preserves caption
15924        let adf_doc = serde_json::json!({
15925            "type": "doc",
15926            "version": 1,
15927            "content": [{
15928                "type": "mediaSingle",
15929                "attrs": {"layout": "center", "width": 400, "widthType": "pixel"},
15930                "content": [
15931                    {
15932                        "type": "media",
15933                        "attrs": {
15934                            "id": "aabbccdd-1234-5678-abcd-aabbccdd1234",
15935                            "type": "file",
15936                            "collection": "contentId-123456",
15937                            "width": 800,
15938                            "height": 600
15939                        }
15940                    },
15941                    {
15942                        "type": "caption",
15943                        "content": [{"type": "text", "text": "An image caption here"}]
15944                    }
15945                ]
15946            }]
15947        });
15948        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
15949        let md = adf_to_markdown(&doc).unwrap();
15950        let doc2 = markdown_to_adf(&md).unwrap();
15951        let ms = &doc2.content[0];
15952        assert_eq!(ms.node_type, "mediaSingle");
15953        let content = ms.content.as_ref().unwrap();
15954        assert_eq!(
15955            content.len(),
15956            2,
15957            "expected media + caption after round-trip"
15958        );
15959        assert_eq!(content[1].node_type, "caption");
15960        let caption_content = content[1].content.as_ref().unwrap();
15961        assert_eq!(
15962            caption_content[0].text.as_deref(),
15963            Some("An image caption here")
15964        );
15965    }
15966
15967    #[test]
15968    fn media_single_caption_with_inline_marks() {
15969        let adf_doc = serde_json::json!({
15970            "type": "doc",
15971            "version": 1,
15972            "content": [{
15973                "type": "mediaSingle",
15974                "attrs": {"layout": "center"},
15975                "content": [
15976                    {
15977                        "type": "media",
15978                        "attrs": {"type": "external", "url": "https://example.com/img.png"}
15979                    },
15980                    {
15981                        "type": "caption",
15982                        "content": [
15983                            {"type": "text", "text": "A "},
15984                            {"type": "text", "text": "bold", "marks": [{"type": "strong"}]},
15985                            {"type": "text", "text": " caption"}
15986                        ]
15987                    }
15988                ]
15989            }]
15990        });
15991        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
15992        let md = adf_to_markdown(&doc).unwrap();
15993        assert!(
15994            md.contains("**bold**"),
15995            "expected bold in caption, got: {md}"
15996        );
15997
15998        let doc2 = markdown_to_adf(&md).unwrap();
15999        let content = doc2.content[0].content.as_ref().unwrap();
16000        assert_eq!(content.len(), 2, "expected media + caption");
16001        assert_eq!(content[1].node_type, "caption");
16002        let caption_inlines = content[1].content.as_ref().unwrap();
16003        let bold_node = caption_inlines
16004            .iter()
16005            .find(|n| n.text.as_deref() == Some("bold"))
16006            .unwrap();
16007        let marks = bold_node.marks.as_ref().unwrap();
16008        assert_eq!(marks[0].mark_type, "strong");
16009    }
16010
16011    #[test]
16012    fn media_single_no_caption_unaffected() {
16013        // Existing mediaSingle without caption should be unaffected
16014        let adf_doc = serde_json::json!({
16015            "type": "doc",
16016            "version": 1,
16017            "content": [{
16018                "type": "mediaSingle",
16019                "attrs": {"layout": "center"},
16020                "content": [{
16021                    "type": "media",
16022                    "attrs": {"type": "external", "url": "https://example.com/img.png"}
16023                }]
16024            }]
16025        });
16026        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16027        let md = adf_to_markdown(&doc).unwrap();
16028        assert!(
16029            !md.contains(":::caption"),
16030            "should not emit caption when none present"
16031        );
16032        let doc2 = markdown_to_adf(&md).unwrap();
16033        let content = doc2.content[0].content.as_ref().unwrap();
16034        assert_eq!(content.len(), 1, "should only have media child");
16035        assert_eq!(content[0].node_type, "media");
16036    }
16037
16038    #[test]
16039    fn media_single_empty_caption_round_trip() {
16040        // Caption node with no content should still round-trip
16041        let adf_doc = serde_json::json!({
16042            "type": "doc",
16043            "version": 1,
16044            "content": [{
16045                "type": "mediaSingle",
16046                "attrs": {"layout": "center"},
16047                "content": [
16048                    {
16049                        "type": "media",
16050                        "attrs": {"type": "external", "url": "https://example.com/img.png"}
16051                    },
16052                    {
16053                        "type": "caption"
16054                    }
16055                ]
16056            }]
16057        });
16058        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16059        let md = adf_to_markdown(&doc).unwrap();
16060        assert!(
16061            md.contains(":::caption"),
16062            "expected :::caption even for empty caption, got: {md}"
16063        );
16064        assert!(
16065            md.contains(":::\n"),
16066            "expected closing ::: fence, got: {md}"
16067        );
16068    }
16069
16070    #[test]
16071    fn media_single_external_caption_round_trip() {
16072        // External image with caption round-trips
16073        let md = "![alt](https://example.com/img.png)\n:::caption\nImage description\n:::\n";
16074        let doc = markdown_to_adf(md).unwrap();
16075        let ms = &doc.content[0];
16076        assert_eq!(ms.node_type, "mediaSingle");
16077        let content = ms.content.as_ref().unwrap();
16078        assert_eq!(content.len(), 2);
16079        assert_eq!(content[0].node_type, "media");
16080        assert_eq!(content[1].node_type, "caption");
16081
16082        let md2 = adf_to_markdown(&doc).unwrap();
16083        let doc2 = markdown_to_adf(&md2).unwrap();
16084        let content2 = doc2.content[0].content.as_ref().unwrap();
16085        assert_eq!(content2.len(), 2);
16086        assert_eq!(content2[1].node_type, "caption");
16087        let caption_text = content2[1].content.as_ref().unwrap();
16088        assert_eq!(caption_text[0].text.as_deref(), Some("Image description"));
16089    }
16090
16091    // ── mediaSingle caption localId tests (issue #524) ─────────────────
16092
16093    #[test]
16094    fn media_single_caption_localid_roundtrip() {
16095        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"}]}]}]}"#;
16096        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16097        let md = adf_to_markdown(&doc).unwrap();
16098        assert!(
16099            md.contains("localId=9da8c2104471"),
16100            "caption localId should appear in markdown: {md}"
16101        );
16102        let rt = markdown_to_adf(&md).unwrap();
16103        let content = rt.content[0].content.as_ref().unwrap();
16104        let caption = &content[1];
16105        assert_eq!(caption.node_type, "caption");
16106        assert_eq!(
16107            caption.attrs.as_ref().unwrap()["localId"],
16108            "9da8c2104471",
16109            "caption localId should round-trip"
16110        );
16111    }
16112
16113    #[test]
16114    fn media_single_caption_without_localid() {
16115        let md = "![Screenshot](){type=file id=abc-123 collection=contentId-456 height=600 width=800}\n:::caption\nPlain caption\n:::\n";
16116        let doc = markdown_to_adf(md).unwrap();
16117        let caption = &doc.content[0].content.as_ref().unwrap()[1];
16118        assert_eq!(caption.node_type, "caption");
16119        assert!(
16120            caption.attrs.is_none(),
16121            "caption without localId should not gain attrs"
16122        );
16123        let md2 = adf_to_markdown(&doc).unwrap();
16124        assert!(
16125            !md2.contains("localId"),
16126            "no localId should appear in output: {md2}"
16127        );
16128    }
16129
16130    #[test]
16131    fn media_single_caption_localid_stripped_when_option_set() {
16132        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"}]}]}]}"#;
16133        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16134        let opts = RenderOptions {
16135            strip_local_ids: true,
16136            ..Default::default()
16137        };
16138        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
16139        assert!(!md.contains("localId"), "localId should be stripped: {md}");
16140    }
16141
16142    #[test]
16143    fn table_width_roundtrip() {
16144        // ADF table with width attribute
16145        let adf_doc = serde_json::json!({
16146            "type": "doc",
16147            "version": 1,
16148            "content": [{
16149                "type": "table",
16150                "attrs": {"layout": "default", "width": 760.0},
16151                "content": [{
16152                    "type": "tableRow",
16153                    "content": [{
16154                        "type": "tableHeader",
16155                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "H"}]}]
16156                    }]
16157                }]
16158            }]
16159        });
16160        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16161        let md = adf_to_markdown(&doc).unwrap();
16162        assert!(
16163            md.contains("width=760.0"),
16164            "expected width=760.0 in markdown (float preserved), got: {md}"
16165        );
16166        // Round-trip back to ADF
16167        let doc2 = markdown_to_adf(&md).unwrap();
16168        let table = &doc2.content[0];
16169        assert_eq!(table.node_type, "table");
16170        let table_attrs = table.attrs.as_ref().unwrap();
16171        assert_eq!(table_attrs["width"], 760.0);
16172        assert!(
16173            table_attrs["width"].is_f64(),
16174            "expected float width to be preserved as f64, got: {:?}",
16175            table_attrs["width"]
16176        );
16177    }
16178
16179    #[test]
16180    fn table_integer_width_roundtrip_preserves_integer() {
16181        // Issue #577: Integer width in ADF must survive roundtrip without being
16182        // coerced to a float.
16183        let adf_doc = serde_json::json!({
16184            "type": "doc",
16185            "version": 1,
16186            "content": [{
16187                "type": "table",
16188                "attrs": {
16189                    "isNumberColumnEnabled": false,
16190                    "layout": "center",
16191                    "localId": "abc-123",
16192                    "width": 1420
16193                },
16194                "content": [{
16195                    "type": "tableRow",
16196                    "content": [{
16197                        "type": "tableCell",
16198                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Cell"}]}]
16199                    }]
16200                }]
16201            }]
16202        });
16203        let doc: crate::atlassian::adf::AdfDocument =
16204            serde_json::from_value(adf_doc.clone()).unwrap();
16205        let md = adf_to_markdown(&doc).unwrap();
16206        assert!(
16207            md.contains("width=1420"),
16208            "expected width=1420 in markdown, got: {md}"
16209        );
16210        assert!(
16211            !md.contains("width=1420.0"),
16212            "integer width should not be rendered with decimal: {md}"
16213        );
16214
16215        let doc2 = markdown_to_adf(&md).unwrap();
16216        let table = &doc2.content[0];
16217        assert_eq!(table.node_type, "table");
16218        let table_attrs = table.attrs.as_ref().unwrap();
16219        assert_eq!(table_attrs["width"], 1420);
16220        assert!(
16221            table_attrs["width"].is_u64() || table_attrs["width"].is_i64(),
16222            "width should remain an integer, got: {:?}",
16223            table_attrs["width"]
16224        );
16225        assert!(
16226            !table_attrs["width"].is_f64(),
16227            "width should not be a float, got: {:?}",
16228            table_attrs["width"]
16229        );
16230
16231        // Full byte-fidelity: re-serialized ADF should match original JSON.
16232        let roundtripped = serde_json::to_value(&doc2).unwrap();
16233        let orig_width = &adf_doc["content"][0]["attrs"]["width"];
16234        let rt_width = &roundtripped["content"][0]["attrs"]["width"];
16235        assert_eq!(
16236            orig_width, rt_width,
16237            "width value must roundtrip byte-for-byte"
16238        );
16239    }
16240
16241    #[test]
16242    fn table_fractional_width_roundtrip() {
16243        // Fractional float widths should also roundtrip faithfully.
16244        let adf_doc = serde_json::json!({
16245            "type": "doc",
16246            "version": 1,
16247            "content": [{
16248                "type": "table",
16249                "attrs": {"layout": "default", "width": 760.5},
16250                "content": [{
16251                    "type": "tableRow",
16252                    "content": [{
16253                        "type": "tableHeader",
16254                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "H"}]}]
16255                    }]
16256                }]
16257            }]
16258        });
16259        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16260        let md = adf_to_markdown(&doc).unwrap();
16261        assert!(
16262            md.contains("width=760.5"),
16263            "expected width=760.5 in markdown, got: {md}"
16264        );
16265        let doc2 = markdown_to_adf(&md).unwrap();
16266        let table_attrs = doc2.content[0].attrs.as_ref().unwrap();
16267        assert_eq!(table_attrs["width"], 760.5);
16268        assert!(table_attrs["width"].is_f64());
16269    }
16270
16271    #[test]
16272    fn pipe_table_integer_width_roundtrip() {
16273        // Exercises the try_table() attrs-on-next-line parsing path.
16274        let md = "| A | B |\n|---|---|\n| 1 | 2 |\n{layout=default width=1420}\n";
16275        let doc = markdown_to_adf(md).unwrap();
16276        let table = &doc.content[0];
16277        assert_eq!(table.node_type, "table");
16278        let attrs = table.attrs.as_ref().unwrap();
16279        assert_eq!(attrs["width"], 1420);
16280        assert!(
16281            attrs["width"].is_u64() || attrs["width"].is_i64(),
16282            "pipe-table width must stay integer, got: {:?}",
16283            attrs["width"]
16284        );
16285    }
16286
16287    #[test]
16288    fn file_media_width_type_roundtrip() {
16289        // mediaSingle with widthType:pixel should survive round-trip
16290        let adf_doc = serde_json::json!({
16291            "type": "doc",
16292            "version": 1,
16293            "content": [{
16294                "type": "mediaSingle",
16295                "attrs": {"layout": "center", "width": 312, "widthType": "pixel"},
16296                "content": [{
16297                    "type": "media",
16298                    "attrs": {
16299                        "type": "file",
16300                        "id": "abc123",
16301                        "collection": "contentId-999",
16302                        "height": 56,
16303                        "width": 312
16304                    }
16305                }]
16306            }]
16307        });
16308        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16309        let md = adf_to_markdown(&doc).unwrap();
16310        assert!(
16311            md.contains("widthType=pixel"),
16312            "expected widthType=pixel in markdown, got: {md}"
16313        );
16314        let doc2 = markdown_to_adf(&md).unwrap();
16315        let ms = &doc2.content[0];
16316        let ms_attrs = ms.attrs.as_ref().unwrap();
16317        assert_eq!(ms_attrs["widthType"], "pixel");
16318        assert_eq!(ms_attrs["width"], 312);
16319    }
16320
16321    #[test]
16322    fn file_media_mode_roundtrip() {
16323        // mediaSingle with mode attr should survive round-trip (issue #431)
16324        let adf_doc = serde_json::json!({
16325            "type": "doc",
16326            "version": 1,
16327            "content": [{
16328                "type": "mediaSingle",
16329                "attrs": {"layout": "wide", "mode": "wide", "width": 1200},
16330                "content": [{
16331                    "type": "media",
16332                    "attrs": {
16333                        "type": "file",
16334                        "id": "abc123",
16335                        "collection": "test",
16336                        "width": 1200,
16337                        "height": 600
16338                    }
16339                }]
16340            }]
16341        });
16342        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16343        let md = adf_to_markdown(&doc).unwrap();
16344        assert!(
16345            md.contains("mode=wide"),
16346            "expected mode=wide in markdown, got: {md}"
16347        );
16348        let doc2 = markdown_to_adf(&md).unwrap();
16349        let ms = &doc2.content[0];
16350        let ms_attrs = ms.attrs.as_ref().unwrap();
16351        assert_eq!(ms_attrs["mode"], "wide");
16352        assert_eq!(ms_attrs["layout"], "wide");
16353        assert_eq!(ms_attrs["width"], 1200);
16354    }
16355
16356    #[test]
16357    fn external_media_mode_roundtrip() {
16358        // External mediaSingle with mode attr should survive round-trip (issue #431)
16359        let adf_doc = serde_json::json!({
16360            "type": "doc",
16361            "version": 1,
16362            "content": [{
16363                "type": "mediaSingle",
16364                "attrs": {"layout": "wide", "mode": "wide"},
16365                "content": [{
16366                    "type": "media",
16367                    "attrs": {
16368                        "type": "external",
16369                        "url": "https://example.com/image.png"
16370                    }
16371                }]
16372            }]
16373        });
16374        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16375        let md = adf_to_markdown(&doc).unwrap();
16376        assert!(
16377            md.contains("mode=wide"),
16378            "expected mode=wide in markdown, got: {md}"
16379        );
16380        let doc2 = markdown_to_adf(&md).unwrap();
16381        let ms = &doc2.content[0];
16382        let ms_attrs = ms.attrs.as_ref().unwrap();
16383        assert_eq!(ms_attrs["mode"], "wide");
16384        assert_eq!(ms_attrs["layout"], "wide");
16385    }
16386
16387    #[test]
16388    fn media_mode_only_roundtrip() {
16389        // mediaSingle with mode but default layout should still preserve mode (issue #431)
16390        let adf_doc = serde_json::json!({
16391            "type": "doc",
16392            "version": 1,
16393            "content": [{
16394                "type": "mediaSingle",
16395                "attrs": {"layout": "center", "mode": "default"},
16396                "content": [{
16397                    "type": "media",
16398                    "attrs": {
16399                        "type": "external",
16400                        "url": "https://example.com/image.png"
16401                    }
16402                }]
16403            }]
16404        });
16405        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16406        let md = adf_to_markdown(&doc).unwrap();
16407        assert!(
16408            md.contains("mode=default"),
16409            "expected mode=default in markdown, got: {md}"
16410        );
16411        let doc2 = markdown_to_adf(&md).unwrap();
16412        let ms = &doc2.content[0];
16413        let ms_attrs = ms.attrs.as_ref().unwrap();
16414        assert_eq!(ms_attrs["mode"], "default");
16415    }
16416
16417    #[test]
16418    fn file_media_hex_localid_roundtrip() {
16419        // Issue #432: short hex localId (non-UUID) must survive round-trip
16420        let adf_doc = serde_json::json!({
16421            "type": "doc",
16422            "version": 1,
16423            "content": [{
16424                "type": "mediaSingle",
16425                "attrs": {"layout": "wide", "width": 1200, "widthType": "pixel"},
16426                "content": [{
16427                    "type": "media",
16428                    "attrs": {
16429                        "type": "file",
16430                        "id": "eb7a9c3b-314e-4458-8200-4b22b67b122e",
16431                        "collection": "contentId-123",
16432                        "height": 484,
16433                        "width": 915,
16434                        "alt": "image.png",
16435                        "localId": "0e79f58ac382"
16436                    }
16437                }]
16438            }]
16439        });
16440        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16441        let md = adf_to_markdown(&doc).unwrap();
16442        assert!(
16443            md.contains("localId=0e79f58ac382"),
16444            "expected localId=0e79f58ac382 in markdown, got: {md}"
16445        );
16446        let doc2 = markdown_to_adf(&md).unwrap();
16447        let ms = &doc2.content[0];
16448        let media = &ms.content.as_ref().unwrap()[0];
16449        let attrs = media.attrs.as_ref().unwrap();
16450        assert_eq!(attrs["localId"], "0e79f58ac382");
16451    }
16452
16453    #[test]
16454    fn file_media_uuid_localid_roundtrip() {
16455        // UUID-format localId must also survive round-trip
16456        let adf_doc = serde_json::json!({
16457            "type": "doc",
16458            "version": 1,
16459            "content": [{
16460                "type": "mediaSingle",
16461                "attrs": {"layout": "center"},
16462                "content": [{
16463                    "type": "media",
16464                    "attrs": {
16465                        "type": "file",
16466                        "id": "abc-123",
16467                        "collection": "contentId-456",
16468                        "height": 100,
16469                        "width": 200,
16470                        "localId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
16471                    }
16472                }]
16473            }]
16474        });
16475        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16476        let md = adf_to_markdown(&doc).unwrap();
16477        assert!(
16478            md.contains("localId=a1b2c3d4-e5f6-7890-abcd-ef1234567890"),
16479            "expected UUID localId in markdown, got: {md}"
16480        );
16481        let doc2 = markdown_to_adf(&md).unwrap();
16482        let media = &doc2.content[0].content.as_ref().unwrap()[0];
16483        let attrs = media.attrs.as_ref().unwrap();
16484        assert_eq!(attrs["localId"], "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
16485    }
16486
16487    #[test]
16488    fn file_media_null_uuid_localid_stripped() {
16489        // Null UUID localId should be stripped (consistent with other node types)
16490        let adf_doc = serde_json::json!({
16491            "type": "doc",
16492            "version": 1,
16493            "content": [{
16494                "type": "mediaSingle",
16495                "attrs": {"layout": "center"},
16496                "content": [{
16497                    "type": "media",
16498                    "attrs": {
16499                        "type": "file",
16500                        "id": "abc-123",
16501                        "collection": "contentId-456",
16502                        "height": 100,
16503                        "width": 200,
16504                        "localId": "00000000-0000-0000-0000-000000000000"
16505                    }
16506                }]
16507            }]
16508        });
16509        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16510        let md = adf_to_markdown(&doc).unwrap();
16511        assert!(
16512            !md.contains("localId="),
16513            "null UUID localId should be stripped, got: {md}"
16514        );
16515    }
16516
16517    #[test]
16518    fn file_media_localid_stripped_when_option_set() {
16519        // localId should be stripped when strip_local_ids option is enabled
16520        let adf_doc = serde_json::json!({
16521            "type": "doc",
16522            "version": 1,
16523            "content": [{
16524                "type": "mediaSingle",
16525                "attrs": {"layout": "center"},
16526                "content": [{
16527                    "type": "media",
16528                    "attrs": {
16529                        "type": "file",
16530                        "id": "abc-123",
16531                        "collection": "contentId-456",
16532                        "height": 100,
16533                        "width": 200,
16534                        "localId": "0e79f58ac382"
16535                    }
16536                }]
16537            }]
16538        });
16539        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16540        let opts = RenderOptions {
16541            strip_local_ids: true,
16542            ..Default::default()
16543        };
16544        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
16545        assert!(
16546            !md.contains("localId="),
16547            "localId should be stripped with strip_local_ids, got: {md}"
16548        );
16549    }
16550
16551    #[test]
16552    fn external_media_localid_roundtrip() {
16553        // localId on external media nodes must also survive round-trip
16554        let adf_doc = serde_json::json!({
16555            "type": "doc",
16556            "version": 1,
16557            "content": [{
16558                "type": "mediaSingle",
16559                "attrs": {"layout": "center"},
16560                "content": [{
16561                    "type": "media",
16562                    "attrs": {
16563                        "type": "external",
16564                        "url": "https://example.com/image.png",
16565                        "alt": "test",
16566                        "localId": "deadbeef1234"
16567                    }
16568                }]
16569            }]
16570        });
16571        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16572        let md = adf_to_markdown(&doc).unwrap();
16573        assert!(
16574            md.contains("localId=deadbeef1234"),
16575            "expected localId in markdown for external media, got: {md}"
16576        );
16577        let doc2 = markdown_to_adf(&md).unwrap();
16578        let media = &doc2.content[0].content.as_ref().unwrap()[0];
16579        let attrs = media.attrs.as_ref().unwrap();
16580        assert_eq!(attrs["localId"], "deadbeef1234");
16581    }
16582
16583    #[test]
16584    fn bracket_in_text_not_parsed_as_link() {
16585        // "[Task] some text (Link)" — the [Task] must NOT be treated as a link anchor
16586        let md = ":check_mark: [Task] Unable to start trial ([Link](https://example.com/link))";
16587        let doc = markdown_to_adf(md).unwrap();
16588        let para = &doc.content[0];
16589        assert_eq!(para.node_type, "paragraph");
16590        let content = para.content.as_ref().unwrap();
16591        // Find the text node containing "[Task]"
16592        let text_nodes: Vec<_> = content.iter().filter(|n| n.node_type == "text").collect();
16593        let has_task_bracket = text_nodes
16594            .iter()
16595            .any(|n| n.text.as_deref().unwrap_or("").contains("[Task]"));
16596        assert!(
16597            has_task_bracket,
16598            "expected [Task] in plain text, nodes: {content:?}"
16599        );
16600        // Also verify the (Link) is a proper link
16601        let link_nodes: Vec<_> = content
16602            .iter()
16603            .filter(|n| {
16604                n.marks
16605                    .as_ref()
16606                    .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "link"))
16607            })
16608            .collect();
16609        assert!(!link_nodes.is_empty(), "expected a link node");
16610        assert_eq!(
16611            link_nodes[0].text.as_deref(),
16612            Some("Link"),
16613            "link text should be 'Link'"
16614        );
16615    }
16616
16617    #[test]
16618    fn empty_paragraph_roundtrip() {
16619        // An empty ADF paragraph node should survive a round-trip through markdown
16620        let mut adf_in = AdfDocument::new();
16621        adf_in.content = vec![
16622            AdfNode::paragraph(vec![AdfNode::text("before")]),
16623            AdfNode::paragraph(vec![]),
16624            AdfNode::paragraph(vec![AdfNode::text("after")]),
16625        ];
16626        let md = adf_to_markdown(&adf_in).unwrap();
16627        let adf_out = markdown_to_adf(&md).unwrap();
16628        assert_eq!(
16629            adf_out.content.len(),
16630            3,
16631            "should have 3 blocks, markdown:\n{md}"
16632        );
16633        assert_eq!(adf_out.content[0].node_type, "paragraph");
16634        assert_eq!(adf_out.content[1].node_type, "paragraph");
16635        assert!(
16636            adf_out.content[1].content.is_none(),
16637            "middle paragraph should be empty"
16638        );
16639        assert_eq!(adf_out.content[2].node_type, "paragraph");
16640    }
16641
16642    #[test]
16643    fn nbsp_paragraph_roundtrip() {
16644        // Issue #411: paragraph with only NBSP should survive round-trip
16645        let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}]}";
16646        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16647        let md = adf_to_markdown(&doc).unwrap();
16648        assert!(
16649            md.contains("::paragraph["),
16650            "NBSP paragraph should use directive form: {md}"
16651        );
16652        let rt = markdown_to_adf(&md).unwrap();
16653        assert_eq!(rt.content.len(), 1, "should have 1 block");
16654        assert_eq!(rt.content[0].node_type, "paragraph");
16655        let text = rt.content[0].content.as_ref().unwrap()[0]
16656            .text
16657            .as_deref()
16658            .unwrap_or("");
16659        assert_eq!(text, "\u{00a0}", "NBSP should survive round-trip");
16660    }
16661
16662    #[test]
16663    fn nbsp_in_nested_expand_roundtrip() {
16664        // Issue #411 real-world case: NBSP paragraph inside nestedExpand
16665        let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"nestedExpand\",\"attrs\":{\"title\":\"Section\"},\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}]}]}";
16666        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16667        let md = adf_to_markdown(&doc).unwrap();
16668        let rt = markdown_to_adf(&md).unwrap();
16669        let ne = &rt.content[0];
16670        assert_eq!(ne.node_type, "nestedExpand");
16671        let inner = ne.content.as_ref().unwrap();
16672        assert_eq!(inner.len(), 1, "should have 1 inner block");
16673        assert_eq!(inner[0].node_type, "paragraph");
16674        let content = inner[0].content.as_ref().unwrap();
16675        assert!(!content.is_empty(), "paragraph should not be empty");
16676        let text = content[0].text.as_deref().unwrap_or("");
16677        assert_eq!(text, "\u{00a0}", "NBSP should survive in nestedExpand");
16678    }
16679
16680    #[test]
16681    fn nbsp_followed_by_content() {
16682        // NBSP paragraph followed by regular content should not interfere
16683        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\"}]}]}";
16684        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16685        let md = adf_to_markdown(&doc).unwrap();
16686        let rt = markdown_to_adf(&md).unwrap();
16687        assert!(rt.content.len() >= 2, "should have at least 2 blocks");
16688        // The second block should be a paragraph with "after"
16689        let after_para = rt.content.iter().find(|n| {
16690            n.node_type == "paragraph"
16691                && n.content
16692                    .as_ref()
16693                    .and_then(|c| c.first())
16694                    .and_then(|n| n.text.as_deref())
16695                    .is_some_and(|t| t.contains("after"))
16696        });
16697        assert!(after_para.is_some(), "should have paragraph with 'after'");
16698    }
16699
16700    #[test]
16701    fn nbsp_paragraph_with_marks_survives() {
16702        // NBSP with bold marks renders as `** **` which contains non-whitespace
16703        // chars and thus doesn't need the directive form — it round-trips naturally
16704        let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\",\"marks\":[{\"type\":\"strong\"}]}]}]}";
16705        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16706        let md = adf_to_markdown(&doc).unwrap();
16707        assert!(md.contains("**"), "should have bold markers: {md}");
16708        let rt = markdown_to_adf(&md).unwrap();
16709        let content = rt.content[0].content.as_ref().unwrap();
16710        assert!(!content.is_empty(), "should preserve content");
16711    }
16712
16713    #[test]
16714    fn regular_paragraph_unchanged() {
16715        // Regression guard: normal paragraphs should NOT use directive form
16716        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}"#;
16717        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16718        let md = adf_to_markdown(&doc).unwrap();
16719        assert!(
16720            !md.contains("::paragraph"),
16721            "regular paragraphs should not use directive form: {md}"
16722        );
16723        assert!(md.contains("hello"));
16724    }
16725
16726    #[test]
16727    fn paragraph_directive_with_content_parsed() {
16728        // ::paragraph[content] should parse to a paragraph with inline nodes
16729        let md = "::paragraph[\u{00a0}]\n";
16730        let doc = markdown_to_adf(md).unwrap();
16731        assert_eq!(doc.content.len(), 1);
16732        assert_eq!(doc.content[0].node_type, "paragraph");
16733        let content = doc.content[0].content.as_ref().unwrap();
16734        assert!(!content.is_empty(), "should have inline content");
16735        assert_eq!(content[0].text.as_deref().unwrap(), "\u{00a0}");
16736    }
16737
16738    #[test]
16739    fn nbsp_paragraph_in_list_item_with_nested_list() {
16740        // Issue #448: NBSP paragraph content lost inside listItem with nested bulletList
16741        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"}]}]}]}]}]}]}"#;
16742        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16743        let md = adf_to_markdown(&doc).unwrap();
16744        let rt = markdown_to_adf(&md).unwrap();
16745        let list = &rt.content[0];
16746        assert_eq!(list.node_type, "bulletList");
16747        let item = &list.content.as_ref().unwrap()[0];
16748        let item_content = item.content.as_ref().unwrap();
16749        assert_eq!(
16750            item_content.len(),
16751            2,
16752            "listItem should have paragraph + nested list, got: {item_content:?}"
16753        );
16754        let para = &item_content[0];
16755        assert_eq!(para.node_type, "paragraph");
16756        let para_content = para
16757            .content
16758            .as_ref()
16759            .expect("paragraph should have content");
16760        assert!(
16761            !para_content.is_empty(),
16762            "NBSP paragraph content should not be empty"
16763        );
16764        assert_eq!(
16765            para_content[0].text.as_deref().unwrap(),
16766            "\u{00a0}",
16767            "NBSP should survive round-trip inside listItem"
16768        );
16769    }
16770
16771    #[test]
16772    fn nbsp_paragraph_in_list_item_with_local_ids() {
16773        // Issue #448: NBSP paragraph with localIds inside listItem with nested list
16774        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"}]}]}]}]}]}]}"#;
16775        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16776        let md = adf_to_markdown(&doc).unwrap();
16777        let rt = markdown_to_adf(&md).unwrap();
16778        let list = &rt.content[0];
16779        let item = &list.content.as_ref().unwrap()[0];
16780        // Check listItem localId
16781        assert_eq!(
16782            item.attrs.as_ref().unwrap()["localId"],
16783            "li-001",
16784            "listItem localId should survive"
16785        );
16786        let item_content = item.content.as_ref().unwrap();
16787        assert_eq!(item_content.len(), 2);
16788        // Check paragraph localId and NBSP content
16789        let para = &item_content[0];
16790        assert_eq!(
16791            para.attrs.as_ref().unwrap()["localId"],
16792            "p-001",
16793            "paragraph localId should survive"
16794        );
16795        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
16796        assert_eq!(text, "\u{00a0}", "NBSP should survive with localIds");
16797    }
16798
16799    #[test]
16800    fn nbsp_paragraph_in_list_item_without_nested_list() {
16801        // NBSP paragraph in a simple listItem (no nested list)
16802        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"}]}]}]}]}"#;
16803        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16804        let md = adf_to_markdown(&doc).unwrap();
16805        let rt = markdown_to_adf(&md).unwrap();
16806        let list = &rt.content[0];
16807        let item = &list.content.as_ref().unwrap()[0];
16808        let para = &item.content.as_ref().unwrap()[0];
16809        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
16810        assert_eq!(text, "\u{00a0}", "NBSP should survive in simple list item");
16811    }
16812
16813    #[test]
16814    fn nbsp_paragraph_in_ordered_list_item_with_nested_list() {
16815        // NBSP paragraph in ordered listItem with nested bulletList
16816        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"}]}]}]}]}]}]}"#;
16817        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16818        let md = adf_to_markdown(&doc).unwrap();
16819        let rt = markdown_to_adf(&md).unwrap();
16820        let list = &rt.content[0];
16821        let item = &list.content.as_ref().unwrap()[0];
16822        let item_content = item.content.as_ref().unwrap();
16823        assert_eq!(item_content.len(), 2);
16824        let para = &item_content[0];
16825        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
16826        assert_eq!(text, "\u{00a0}", "NBSP should survive in ordered list item");
16827    }
16828
16829    #[test]
16830    fn list_item_leading_space_preserved() {
16831        // Leading space in list item text must not be stripped
16832        let md = "- hello world\n- - text";
16833        let doc = markdown_to_adf(md).unwrap();
16834        let list = &doc.content[0];
16835        assert_eq!(list.node_type, "bulletList");
16836        let items = list.content.as_ref().unwrap();
16837        // First item: "hello world" (no leading space, unchanged)
16838        let first_para = &items[0].content.as_ref().unwrap()[0];
16839        let first_text = &first_para.content.as_ref().unwrap()[0];
16840        assert_eq!(first_text.text.as_deref(), Some("hello world"));
16841    }
16842
16843    #[test]
16844    fn list_item_leading_space_not_stripped() {
16845        // When the markdown list item content has a leading space (e.g. " :emoji:"),
16846        // that space must reach parse_inline as-is.
16847        let md = "-  leading space text";
16848        let doc = markdown_to_adf(md).unwrap();
16849        let list = &doc.content[0];
16850        let items = list.content.as_ref().unwrap();
16851        let para = &items[0].content.as_ref().unwrap()[0];
16852        let text_node = &para.content.as_ref().unwrap()[0];
16853        // After "- " (2 chars), trim_end keeps the leading space: " leading space text"
16854        assert_eq!(
16855            text_node.text.as_deref(),
16856            Some(" leading space text"),
16857            "leading space should be preserved"
16858        );
16859    }
16860
16861    // ── Nested container directive tests ───────────────────────────
16862
16863    // ── hardBreak in table cell tests ────────────────────────────
16864
16865    #[test]
16866    fn hardbreak_in_cell_uses_directive_table() {
16867        // A table cell with a hardBreak should NOT use pipe syntax
16868        // because the newline would break the row
16869        let adf = AdfDocument {
16870            version: 1,
16871            doc_type: "doc".to_string(),
16872            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
16873                AdfNode::table_cell(vec![AdfNode::paragraph(vec![
16874                    AdfNode::text("before"),
16875                    AdfNode::hard_break(),
16876                    AdfNode::text("after"),
16877                ])]),
16878            ])])],
16879        };
16880        let md = adf_to_markdown(&adf).unwrap();
16881        // Should render as directive table, not pipe table
16882        assert!(
16883            md.contains(":::td") || md.contains("::::table"),
16884            "Table with hardBreak should use directive form, got:\n{md}"
16885        );
16886        assert!(
16887            !md.contains("| before"),
16888            "Should NOT use pipe syntax with hardBreak"
16889        );
16890    }
16891
16892    #[test]
16893    fn hardbreak_in_cell_roundtrips() {
16894        // Verify the directive table form preserves the hardBreak on round-trip
16895        let adf = AdfDocument {
16896            version: 1,
16897            doc_type: "doc".to_string(),
16898            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
16899                AdfNode::table_cell(vec![AdfNode::paragraph(vec![
16900                    AdfNode::text("line one"),
16901                    AdfNode::hard_break(),
16902                    AdfNode::text("line two"),
16903                ])]),
16904            ])])],
16905        };
16906        let md = adf_to_markdown(&adf).unwrap();
16907        let roundtripped = markdown_to_adf(&md).unwrap();
16908
16909        // Should still have one table with one row with one cell
16910        assert_eq!(roundtripped.content.len(), 1);
16911        assert_eq!(roundtripped.content[0].node_type, "table");
16912        let rows = roundtripped.content[0].content.as_ref().unwrap();
16913        assert_eq!(
16914            rows.len(),
16915            1,
16916            "Should have exactly 1 row, got {}",
16917            rows.len()
16918        );
16919    }
16920
16921    #[test]
16922    fn hardbreak_in_paragraph_roundtrips() {
16923        // Issue #373: hardBreak absorbed into preceding text node
16924        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
16925          {"type":"text","text":"line one"},
16926          {"type":"hardBreak"},
16927          {"type":"text","text":"line two"}
16928        ]}]}"#;
16929        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16930        let md = adf_to_markdown(&doc).unwrap();
16931        let round_tripped = markdown_to_adf(&md).unwrap();
16932        let inlines = round_tripped.content[0].content.as_ref().unwrap();
16933        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
16934        assert_eq!(
16935            types,
16936            vec!["text", "hardBreak", "text"],
16937            "hardBreak should be preserved, got: {types:?}"
16938        );
16939        assert_eq!(inlines[0].text.as_deref(), Some("line one"));
16940        assert_eq!(inlines[2].text.as_deref(), Some("line two"));
16941    }
16942
16943    #[test]
16944    fn consecutive_hardbreaks_in_paragraph_roundtrip() {
16945        // Issue #410: consecutive hardBreak nodes collapsed on round-trip
16946        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
16947          {"type":"text","text":"before"},
16948          {"type":"hardBreak"},
16949          {"type":"hardBreak"},
16950          {"type":"text","text":"after"}
16951        ]}]}"#;
16952        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16953        let md = adf_to_markdown(&doc).unwrap();
16954        let round_tripped = markdown_to_adf(&md).unwrap();
16955        assert_eq!(
16956            round_tripped.content.len(),
16957            1,
16958            "Should remain a single paragraph, got {} blocks",
16959            round_tripped.content.len()
16960        );
16961        let inlines = round_tripped.content[0].content.as_ref().unwrap();
16962        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
16963        assert_eq!(
16964            types,
16965            vec!["text", "hardBreak", "hardBreak", "text"],
16966            "Both hardBreaks should be preserved, got: {types:?}"
16967        );
16968        assert_eq!(inlines[0].text.as_deref(), Some("before"));
16969        assert_eq!(inlines[3].text.as_deref(), Some("after"));
16970    }
16971
16972    #[test]
16973    fn hardbreak_only_paragraph_roundtrips() {
16974        // Issue #410: paragraph whose only content is a hardBreak is dropped
16975        let adf_json = r#"{"version":1,"type":"doc","content":[
16976          {"type":"paragraph","content":[{"type":"hardBreak"}]}
16977        ]}"#;
16978        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16979        let md = adf_to_markdown(&doc).unwrap();
16980        let round_tripped = markdown_to_adf(&md).unwrap();
16981        assert_eq!(
16982            round_tripped.content.len(),
16983            1,
16984            "Paragraph should not be dropped, got {} blocks",
16985            round_tripped.content.len()
16986        );
16987        let inlines = round_tripped.content[0].content.as_ref().unwrap();
16988        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
16989        assert_eq!(
16990            types,
16991            vec!["hardBreak"],
16992            "hardBreak-only paragraph should preserve its content, got: {types:?}"
16993        );
16994    }
16995
16996    #[test]
16997    fn issue_410_full_reproducer_roundtrips() {
16998        // Full reproducer from issue #410: consecutive hardBreaks + hardBreak-only paragraph
16999        let adf_json = r#"{"version":1,"type":"doc","content":[
17000          {"type":"paragraph","content":[
17001            {"type":"text","text":"before"},
17002            {"type":"hardBreak"},
17003            {"type":"hardBreak"},
17004            {"type":"text","text":"after"}
17005          ]},
17006          {"type":"paragraph","content":[
17007            {"type":"hardBreak"}
17008          ]}
17009        ]}"#;
17010        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17011        let md = adf_to_markdown(&doc).unwrap();
17012        let round_tripped = markdown_to_adf(&md).unwrap();
17013        assert_eq!(
17014            round_tripped.content.len(),
17015            2,
17016            "Should have exactly 2 paragraphs, got {}",
17017            round_tripped.content.len()
17018        );
17019        // First paragraph: text, hardBreak, hardBreak, text
17020        let p1 = round_tripped.content[0].content.as_ref().unwrap();
17021        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
17022        assert_eq!(types1, vec!["text", "hardBreak", "hardBreak", "text"]);
17023        // Second paragraph: hardBreak only
17024        let p2 = round_tripped.content[1].content.as_ref().unwrap();
17025        let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
17026        assert_eq!(types2, vec!["hardBreak"]);
17027    }
17028
17029    #[test]
17030    fn trailing_space_hardbreak_still_parsed() {
17031        // Backward compatibility: trailing-space hardBreak (old JFM format) still parses
17032        let md = "line one  \nline two\n";
17033        let doc = markdown_to_adf(md).unwrap();
17034        let inlines = doc.content[0].content.as_ref().unwrap();
17035        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17036        assert_eq!(
17037            types,
17038            vec!["text", "hardBreak", "text"],
17039            "Trailing-space hardBreak should still parse, got: {types:?}"
17040        );
17041    }
17042
17043    #[test]
17044    fn trailing_hardbreak_at_end_of_paragraph_roundtrips() {
17045        // A paragraph ending with a hardBreak (no text after it)
17046        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
17047          {"type":"text","text":"text"},
17048          {"type":"hardBreak"}
17049        ]}]}"#;
17050        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17051        let md = adf_to_markdown(&doc).unwrap();
17052        let round_tripped = markdown_to_adf(&md).unwrap();
17053        let inlines = round_tripped.content[0].content.as_ref().unwrap();
17054        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17055        assert_eq!(
17056            types,
17057            vec!["text", "hardBreak"],
17058            "Trailing hardBreak should be preserved, got: {types:?}"
17059        );
17060    }
17061
17062    #[test]
17063    #[test]
17064    fn table_with_header_row_uses_pipe_syntax() {
17065        // A table with tableHeader in the first row should use pipe syntax
17066        let adf = AdfDocument {
17067            version: 1,
17068            doc_type: "doc".to_string(),
17069            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17070                AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("header cell")])]),
17071            ])])],
17072        };
17073        let md = adf_to_markdown(&adf).unwrap();
17074        assert!(
17075            md.contains("| header cell |"),
17076            "Table with header row should use pipe syntax, got:\n{md}"
17077        );
17078    }
17079
17080    #[test]
17081    fn table_without_header_row_uses_directive_syntax() {
17082        // Issue #392: tableCell-only first row must use directive syntax
17083        // to avoid converting tableCell → tableHeader on round-trip
17084        let adf = AdfDocument {
17085            version: 1,
17086            doc_type: "doc".to_string(),
17087            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17088                AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("simple cell")])]),
17089            ])])],
17090        };
17091        let md = adf_to_markdown(&adf).unwrap();
17092        assert!(
17093            md.contains("::::table"),
17094            "Table without header row should use directive syntax, got:\n{md}"
17095        );
17096    }
17097
17098    #[test]
17099    fn tablecell_first_row_preserved_on_roundtrip() {
17100        // Issue #392: tableCell in first row round-trips as tableHeader
17101        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{},"content":[
17102          {"type":"tableRow","content":[
17103            {"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"row1 cell"}]}]}
17104          ]},
17105          {"type":"tableRow","content":[
17106            {"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"row2 cell"}]}]}
17107          ]}
17108        ]}]}"#;
17109        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17110        let md = adf_to_markdown(&doc).unwrap();
17111        let round_tripped = markdown_to_adf(&md).unwrap();
17112        let rows = round_tripped.content[0].content.as_ref().unwrap();
17113        let row0_cell = &rows[0].content.as_ref().unwrap()[0];
17114        assert_eq!(
17115            row0_cell.node_type, "tableCell",
17116            "first row cell should remain tableCell, got: {}",
17117            row0_cell.node_type
17118        );
17119        let row1_cell = &rows[1].content.as_ref().unwrap()[0];
17120        assert_eq!(row1_cell.node_type, "tableCell");
17121    }
17122
17123    #[test]
17124    fn mixed_header_and_cell_first_row_uses_pipe() {
17125        // A first row with at least one tableHeader qualifies for pipe syntax
17126        let adf = AdfDocument {
17127            version: 1,
17128            doc_type: "doc".to_string(),
17129            content: vec![AdfNode::table(vec![
17130                AdfNode::table_row(vec![
17131                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
17132                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
17133                ]),
17134                AdfNode::table_row(vec![
17135                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C1")])]),
17136                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
17137                ]),
17138            ])],
17139        };
17140        let md = adf_to_markdown(&adf).unwrap();
17141        assert!(
17142            md.contains("| H1 |"),
17143            "Table with header first row should use pipe syntax, got:\n{md}"
17144        );
17145        assert!(!md.contains("::::table"), "should not use directive syntax");
17146    }
17147
17148    // ── Issue #579: pipes in pipe-table cells ─────────────────────
17149
17150    #[test]
17151    fn render_pipe_table_escapes_pipe_in_code_span_cell() {
17152        // A code-marked text node with a literal `|` in a pipe-table cell
17153        // must emit `\|` so the column separator is unambiguous.
17154        let adf = AdfDocument {
17155            version: 1,
17156            doc_type: "doc".to_string(),
17157            content: vec![AdfNode::table(vec![
17158                AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
17159                    AdfNode::text("Header"),
17160                ])])]),
17161                AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
17162                    AdfNode::text_with_marks("a|b", vec![AdfMark::code()]),
17163                ])])]),
17164            ])],
17165        };
17166        let md = adf_to_markdown(&adf).unwrap();
17167        assert!(
17168            md.contains(r"`a\|b`"),
17169            "Pipe inside code span must be escaped, got:\n{md}"
17170        );
17171    }
17172
17173    #[test]
17174    fn render_pipe_table_escapes_pipe_in_plain_text_cell() {
17175        let adf = AdfDocument {
17176            version: 1,
17177            doc_type: "doc".to_string(),
17178            content: vec![AdfNode::table(vec![
17179                AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
17180                    AdfNode::text("Header"),
17181                ])])]),
17182                AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
17183                    AdfNode::text("x|y"),
17184                ])])]),
17185            ])],
17186        };
17187        let md = adf_to_markdown(&adf).unwrap();
17188        assert!(
17189            md.contains(r"x\|y"),
17190            "Pipe inside plain-text cell must be escaped, got:\n{md}"
17191        );
17192    }
17193
17194    #[test]
17195    fn code_span_with_pipe_in_table_cell_roundtrips() {
17196        // Issue #579 reproducer: code span containing `|` in a pipe-table cell.
17197        let adf_json = r#"{
17198            "version": 1,
17199            "type": "doc",
17200            "content": [{
17201                "type": "table",
17202                "attrs": {"isNumberColumnEnabled": false, "layout": "default", "localId": "abc-789"},
17203                "content": [
17204                    {"type": "tableRow", "content": [
17205                        {"type": "tableHeader", "attrs": {}, "content": [
17206                            {"type": "paragraph", "content": [{"type": "text", "text": "Before"}]}
17207                        ]},
17208                        {"type": "tableHeader", "attrs": {}, "content": [
17209                            {"type": "paragraph", "content": [{"type": "text", "text": "After"}]}
17210                        ]}
17211                    ]},
17212                    {"type": "tableRow", "content": [
17213                        {"type": "tableCell", "attrs": {}, "content": [
17214                            {"type": "paragraph", "content": [
17215                                {"type": "text", "text": "parse(json).extract[T]", "marks": [{"type": "code"}]}
17216                            ]}
17217                        ]},
17218                        {"type": "tableCell", "attrs": {}, "content": [
17219                            {"type": "paragraph", "content": [
17220                                {"type": "text", "text": "parser.decode[T|json]", "marks": [{"type": "code"}]}
17221                            ]}
17222                        ]}
17223                    ]}
17224                ]
17225            }]
17226        }"#;
17227        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17228        let md = adf_to_markdown(&doc).unwrap();
17229        let round_tripped = markdown_to_adf(&md).unwrap();
17230
17231        let rows = round_tripped.content[0].content.as_ref().unwrap();
17232        assert_eq!(
17233            rows.len(),
17234            2,
17235            "Table should have 2 rows, got: {}",
17236            rows.len()
17237        );
17238
17239        let body_row = rows[1].content.as_ref().unwrap();
17240        assert_eq!(
17241            body_row.len(),
17242            2,
17243            "Body row should have 2 cells (not split by the pipe), got: {}",
17244            body_row.len()
17245        );
17246
17247        let second_cell = &body_row[1];
17248        let para = second_cell.content.as_ref().unwrap().first().unwrap();
17249        let inlines = para.content.as_ref().unwrap();
17250        assert_eq!(inlines.len(), 1, "Cell should have a single text node");
17251        assert_eq!(
17252            inlines[0].text.as_deref(),
17253            Some("parser.decode[T|json]"),
17254            "Code-span text must be preserved with literal pipe"
17255        );
17256        let marks = inlines[0]
17257            .marks
17258            .as_ref()
17259            .expect("code mark must be preserved");
17260        assert!(
17261            marks.iter().any(|m| m.mark_type == "code"),
17262            "text node should carry the code mark"
17263        );
17264    }
17265
17266    #[test]
17267    fn plain_text_pipe_in_table_cell_roundtrips() {
17268        // Plain text with `|` in a pipe-table cell should also survive.
17269        let adf = AdfDocument {
17270            version: 1,
17271            doc_type: "doc".to_string(),
17272            content: vec![AdfNode::table(vec![
17273                AdfNode::table_row(vec![
17274                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
17275                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
17276                ]),
17277                AdfNode::table_row(vec![
17278                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("a|b")])]),
17279                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("c")])]),
17280                ]),
17281            ])],
17282        };
17283        let md = adf_to_markdown(&adf).unwrap();
17284        let round_tripped = markdown_to_adf(&md).unwrap();
17285        let rows = round_tripped.content[0].content.as_ref().unwrap();
17286        let body_row = rows[1].content.as_ref().unwrap();
17287        assert_eq!(
17288            body_row.len(),
17289            2,
17290            "Body row should keep 2 cells, got: {}",
17291            body_row.len()
17292        );
17293        let first_cell_text = body_row[0].content.as_ref().unwrap()[0]
17294            .content
17295            .as_ref()
17296            .unwrap()[0]
17297            .text
17298            .as_deref();
17299        assert_eq!(first_cell_text, Some("a|b"));
17300    }
17301
17302    #[test]
17303    fn cell_contains_hard_break_true() {
17304        let para = AdfNode::paragraph(vec![
17305            AdfNode::text("a"),
17306            AdfNode::hard_break(),
17307            AdfNode::text("b"),
17308        ]);
17309        assert!(cell_contains_hard_break(&para));
17310    }
17311
17312    #[test]
17313    fn cell_contains_hard_break_false() {
17314        let para = AdfNode::paragraph(vec![AdfNode::text("no break here")]);
17315        assert!(!cell_contains_hard_break(&para));
17316    }
17317
17318    #[test]
17319    fn cell_contains_hard_break_empty() {
17320        let para = AdfNode::paragraph(vec![]);
17321        assert!(!cell_contains_hard_break(&para));
17322    }
17323
17324    // ── Multi-paragraph container tests ──────────────────────────
17325
17326    #[test]
17327    fn multi_paragraph_panel_roundtrips() {
17328        let adf = AdfDocument {
17329            version: 1,
17330            doc_type: "doc".to_string(),
17331            content: vec![AdfNode {
17332                node_type: "panel".to_string(),
17333                attrs: Some(serde_json::json!({"panelType": "info"})),
17334                content: Some(vec![
17335                    AdfNode::paragraph(vec![AdfNode::text("First paragraph.")]),
17336                    AdfNode::paragraph(vec![AdfNode::text("Second paragraph.")]),
17337                ]),
17338                text: None,
17339                marks: None,
17340                local_id: None,
17341                parameters: None,
17342            }],
17343        };
17344
17345        let md = adf_to_markdown(&adf).unwrap();
17346        // Should have blank line between paragraphs inside the panel
17347        assert!(
17348            md.contains("First paragraph.\n\nSecond paragraph."),
17349            "Panel should have blank line between paragraphs, got:\n{md}"
17350        );
17351
17352        // Round-trip should preserve two separate paragraphs
17353        let roundtripped = markdown_to_adf(&md).unwrap();
17354        assert_eq!(roundtripped.content.len(), 1);
17355        assert_eq!(roundtripped.content[0].node_type, "panel");
17356        let panel_content = roundtripped.content[0].content.as_ref().unwrap();
17357        assert_eq!(
17358            panel_content.len(),
17359            2,
17360            "Panel should have 2 paragraphs after round-trip, got {}",
17361            panel_content.len()
17362        );
17363    }
17364
17365    #[test]
17366    fn multi_paragraph_expand_roundtrips() {
17367        let adf = AdfDocument {
17368            version: 1,
17369            doc_type: "doc".to_string(),
17370            content: vec![AdfNode {
17371                node_type: "expand".to_string(),
17372                attrs: Some(serde_json::json!({"title": "Details"})),
17373                content: Some(vec![
17374                    AdfNode::paragraph(vec![AdfNode::text("Para one.")]),
17375                    AdfNode::paragraph(vec![AdfNode::text("Para two.")]),
17376                ]),
17377                text: None,
17378                marks: None,
17379                local_id: None,
17380                parameters: None,
17381            }],
17382        };
17383
17384        let md = adf_to_markdown(&adf).unwrap();
17385        let roundtripped = markdown_to_adf(&md).unwrap();
17386        let expand_content = roundtripped.content[0].content.as_ref().unwrap();
17387        assert_eq!(
17388            expand_content.len(),
17389            2,
17390            "Expand should have 2 paragraphs after round-trip, got {}",
17391            expand_content.len()
17392        );
17393    }
17394
17395    #[test]
17396    fn consecutive_nested_expands_in_table_cell_roundtrip() {
17397        let cell_content = vec![
17398            AdfNode {
17399                node_type: "nestedExpand".to_string(),
17400                attrs: Some(serde_json::json!({"title": "First"})),
17401                content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("item 1")])]),
17402                text: None,
17403                marks: None,
17404                local_id: None,
17405                parameters: None,
17406            },
17407            AdfNode {
17408                node_type: "nestedExpand".to_string(),
17409                attrs: Some(serde_json::json!({"title": "Second"})),
17410                content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("item 2")])]),
17411                text: None,
17412                marks: None,
17413                local_id: None,
17414                parameters: None,
17415            },
17416        ];
17417        let adf = AdfDocument {
17418            version: 1,
17419            doc_type: "doc".to_string(),
17420            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17421                AdfNode::table_cell(cell_content),
17422            ])])],
17423        };
17424
17425        let md = adf_to_markdown(&adf).unwrap();
17426        assert!(
17427            md.contains(":::\n\n:::nested-expand"),
17428            "Should have blank line between consecutive nested-expands in cell, got:\n{md}"
17429        );
17430
17431        let rt = markdown_to_adf(&md).unwrap();
17432        let cell = &rt.content[0].content.as_ref().unwrap()[0]
17433            .content
17434            .as_ref()
17435            .unwrap()[0];
17436        let cell_nodes = cell.content.as_ref().unwrap();
17437        let expand_count = cell_nodes
17438            .iter()
17439            .filter(|n| n.node_type == "nestedExpand")
17440            .count();
17441        assert_eq!(
17442            expand_count, 2,
17443            "Both nested-expands should survive round-trip, got {expand_count}"
17444        );
17445    }
17446
17447    #[test]
17448    fn multi_paragraph_in_table_cell_roundtrip() {
17449        // Two paragraphs inside a directive table cell should survive round-trip
17450        let adf = AdfDocument {
17451            version: 1,
17452            doc_type: "doc".to_string(),
17453            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17454                AdfNode::table_cell(vec![
17455                    AdfNode::paragraph(vec![AdfNode::text("Para one.")]),
17456                    AdfNode::paragraph(vec![AdfNode::text("Para two.")]),
17457                ]),
17458            ])])],
17459        };
17460
17461        let md = adf_to_markdown(&adf).unwrap();
17462        assert!(
17463            md.contains("Para one.\n\nPara two."),
17464            "Should have blank line between paragraphs in cell, got:\n{md}"
17465        );
17466
17467        let rt = markdown_to_adf(&md).unwrap();
17468        let cell = &rt.content[0].content.as_ref().unwrap()[0]
17469            .content
17470            .as_ref()
17471            .unwrap()[0];
17472        let para_count = cell
17473            .content
17474            .as_ref()
17475            .unwrap()
17476            .iter()
17477            .filter(|n| n.node_type == "paragraph")
17478            .count();
17479        assert_eq!(para_count, 2, "Both paragraphs should survive round-trip");
17480    }
17481
17482    #[test]
17483    fn panel_inside_table_cell_roundtrip() {
17484        // A panel inside a directive table cell
17485        let adf = AdfDocument {
17486            version: 1,
17487            doc_type: "doc".to_string(),
17488            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17489                AdfNode::table_cell(vec![
17490                    AdfNode::paragraph(vec![AdfNode::text("Before panel.")]),
17491                    AdfNode {
17492                        node_type: "panel".to_string(),
17493                        attrs: Some(serde_json::json!({"panelType": "info"})),
17494                        content: Some(vec![AdfNode::paragraph(vec![AdfNode::text(
17495                            "Panel content",
17496                        )])]),
17497                        text: None,
17498                        marks: None,
17499                        local_id: None,
17500                        parameters: None,
17501                    },
17502                ]),
17503            ])])],
17504        };
17505
17506        let md = adf_to_markdown(&adf).unwrap();
17507        assert!(
17508            md.contains(":::panel"),
17509            "Should contain panel directive, got:\n{md}"
17510        );
17511
17512        let rt = markdown_to_adf(&md).unwrap();
17513        let cell = &rt.content[0].content.as_ref().unwrap()[0]
17514            .content
17515            .as_ref()
17516            .unwrap()[0];
17517        let has_panel = cell
17518            .content
17519            .as_ref()
17520            .unwrap()
17521            .iter()
17522            .any(|n| n.node_type == "panel");
17523        assert!(has_panel, "Panel should survive round-trip in table cell");
17524    }
17525
17526    #[test]
17527    fn three_consecutive_expands_in_table_cell() {
17528        let make_expand = |title: &str| AdfNode {
17529            node_type: "nestedExpand".to_string(),
17530            attrs: Some(serde_json::json!({"title": title})),
17531            content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("content")])]),
17532            text: None,
17533            marks: None,
17534            local_id: None,
17535            parameters: None,
17536        };
17537        let adf = AdfDocument {
17538            version: 1,
17539            doc_type: "doc".to_string(),
17540            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17541                AdfNode::table_cell(vec![
17542                    make_expand("First"),
17543                    make_expand("Second"),
17544                    make_expand("Third"),
17545                ]),
17546            ])])],
17547        };
17548
17549        let md = adf_to_markdown(&adf).unwrap();
17550        let rt = markdown_to_adf(&md).unwrap();
17551        let cell = &rt.content[0].content.as_ref().unwrap()[0]
17552            .content
17553            .as_ref()
17554            .unwrap()[0];
17555        let expand_count = cell
17556            .content
17557            .as_ref()
17558            .unwrap()
17559            .iter()
17560            .filter(|n| n.node_type == "nestedExpand")
17561            .count();
17562        assert_eq!(expand_count, 3, "All 3 expands should survive round-trip");
17563    }
17564
17565    // ── Nested container directive tests ───────────────────────────
17566
17567    #[test]
17568    fn nested_expand_inside_panel() {
17569        // Issue #714: the converter still produces panel→expand at the AST
17570        // level (this is what users may type), but the document fails ADF
17571        // schema validation, surfacing an actionable error before the API
17572        // call rather than an opaque Confluence HTTP 500.
17573        let md = ":::panel{type=info}\n:::expand{title=\"Details\"}\nHidden content\n:::\nMore panel content\n:::";
17574        let adf = markdown_to_adf(md).unwrap();
17575
17576        let err = crate::atlassian::adf_validated::validate(&adf).unwrap_err();
17577        assert!(err.violations.iter().any(|v| matches!(
17578            v,
17579            crate::atlassian::adf_schema::AdfSchemaViolation::DisallowedChild {
17580                parent_type, child_type, ..
17581            } if parent_type == "panel" && child_type == "expand"
17582        )));
17583    }
17584
17585    #[test]
17586    fn nested_expand_inside_table_cell() {
17587        // Issue #714: tableCell → expand is a Confluence content-model
17588        // violation. Table cells require `nestedExpand` instead. Validation
17589        // catches this before the API call.
17590        let md = "::::table\n:::tr\n:::td\n:::expand{title=\"Details\"}\nExpand content\n:::\n:::\n:::\n::::";
17591        let adf = markdown_to_adf(md).unwrap();
17592
17593        let err = crate::atlassian::adf_validated::validate(&adf).unwrap_err();
17594        assert!(err.violations.iter().any(|v| matches!(
17595            v,
17596            crate::atlassian::adf_schema::AdfSchemaViolation::DisallowedChild {
17597                parent_type, child_type, ..
17598            } if parent_type == "tableCell" && child_type == "expand"
17599        )));
17600    }
17601
17602    #[test]
17603    fn nested_expand_inside_layout_column() {
17604        // Issue #714 sanity check: `expand` inside a `layoutColumn` is
17605        // legitimate per the ADF schema and must NOT trigger validation.
17606        // Note: layoutSection requires 2..=3 columns per #733's quantifier
17607        // checks, so the markdown declares two columns.
17608        let md = ":::layout\n:::column{width=50}\n:::expand{title=\"Col Expand\"}\nExpanded\n:::\n:::\n:::column{width=50}\nFiller paragraph.\n:::\n:::";
17609        let adf = markdown_to_adf(md).unwrap();
17610
17611        assert_eq!(adf.content.len(), 1);
17612        assert_eq!(adf.content[0].node_type, "layoutSection");
17613
17614        let columns = adf.content[0].content.as_ref().unwrap();
17615        assert_eq!(columns.len(), 2);
17616        let col_content = columns[0].content.as_ref().unwrap();
17617        assert!(
17618            col_content.iter().any(|n| n.node_type == "expand"),
17619            "Column should contain an expand node, got: {:?}",
17620            col_content.iter().map(|n| &n.node_type).collect::<Vec<_>>()
17621        );
17622
17623        // Validation must not flag this legitimate nesting.
17624        crate::atlassian::adf_validated::validate(&adf).unwrap();
17625    }
17626
17627    #[test]
17628    fn expand_localid_in_directive_attrs() {
17629        // Issue #412: localId should be in directive attrs, not trailing text
17630        let adf_json = r#"{"version":1,"type":"doc","content":[
17631          {"type":"expand","attrs":{"localId":"exp-001","title":"Details"},"content":[
17632            {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17633          ]}
17634        ]}"#;
17635        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17636        let md = adf_to_markdown(&doc).unwrap();
17637        assert!(
17638            md.contains("localId=exp-001"),
17639            "should contain localId: {md}"
17640        );
17641        assert!(
17642            md.contains(":::expand{"),
17643            "should have expand directive with attrs: {md}"
17644        );
17645        assert!(
17646            !md.contains(":::\n{localId="),
17647            "localId should NOT be trailing: {md}"
17648        );
17649    }
17650
17651    #[test]
17652    fn expand_localid_roundtrip() {
17653        let adf_json = r#"{"version":1,"type":"doc","content":[
17654          {"type":"expand","attrs":{"localId":"exp-001","title":"Details"},"content":[
17655            {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17656          ]}
17657        ]}"#;
17658        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17659        let md = adf_to_markdown(&doc).unwrap();
17660        let rt = markdown_to_adf(&md).unwrap();
17661        let expand = &rt.content[0];
17662        assert_eq!(expand.node_type, "expand");
17663        assert_eq!(
17664            expand.local_id.as_deref(),
17665            Some("exp-001"),
17666            "expand localId should survive round-trip"
17667        );
17668        assert_eq!(
17669            expand.attrs.as_ref().unwrap()["title"],
17670            "Details",
17671            "expand title should survive round-trip"
17672        );
17673    }
17674
17675    #[test]
17676    fn nested_expand_localid_roundtrip() {
17677        let adf_json = r#"{"version":1,"type":"doc","content":[
17678          {"type":"nestedExpand","attrs":{"localId":"ne-001","title":"S"},"content":[
17679            {"type":"paragraph","content":[{"type":"text","text":"content"}]}
17680          ]}
17681        ]}"#;
17682        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17683        let md = adf_to_markdown(&doc).unwrap();
17684        assert!(
17685            md.contains(":::nested-expand{"),
17686            "should have directive: {md}"
17687        );
17688        assert!(md.contains("localId=ne-001"), "should have localId: {md}");
17689        let rt = markdown_to_adf(&md).unwrap();
17690        let ne = &rt.content[0];
17691        assert_eq!(ne.node_type, "nestedExpand");
17692        assert_eq!(ne.local_id.as_deref(), Some("ne-001"));
17693    }
17694
17695    #[test]
17696    fn nested_expand_localid_followed_by_content() {
17697        // Issue #412 reproducer: localId must not leak into following paragraph
17698        let adf_json = "{\
17699            \"version\":1,\"type\":\"doc\",\"content\":[\
17700              {\"type\":\"nestedExpand\",\"attrs\":{\"localId\":\"exp-001\",\"title\":\"S\"},\"content\":[\
17701                {\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}\
17702              ]},\
17703              {\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"after\"}]}\
17704            ]}";
17705        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17706        let md = adf_to_markdown(&doc).unwrap();
17707        let rt = markdown_to_adf(&md).unwrap();
17708        // nestedExpand should have localId
17709        let ne = &rt.content[0];
17710        assert_eq!(ne.node_type, "nestedExpand");
17711        assert_eq!(
17712            ne.local_id.as_deref(),
17713            Some("exp-001"),
17714            "nestedExpand should preserve localId"
17715        );
17716        // Following paragraph should contain "after", not "{localId=...}"
17717        let para = &rt.content[1];
17718        assert_eq!(para.node_type, "paragraph");
17719        let text = para.content.as_ref().unwrap()[0]
17720            .text
17721            .as_deref()
17722            .unwrap_or("");
17723        assert!(
17724            !text.contains("localId"),
17725            "following paragraph should not contain localId: {text}"
17726        );
17727        assert!(
17728            text.contains("after"),
17729            "following paragraph should contain 'after': {text}"
17730        );
17731    }
17732
17733    #[test]
17734    fn expand_localid_without_title() {
17735        let adf_json = r#"{"version":1,"type":"doc","content":[
17736          {"type":"expand","attrs":{"localId":"exp-002"},"content":[
17737            {"type":"paragraph","content":[{"type":"text","text":"no title"}]}
17738          ]}
17739        ]}"#;
17740        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17741        let md = adf_to_markdown(&doc).unwrap();
17742        assert!(
17743            md.contains(":::expand{localId=exp-002}"),
17744            "should have localId without title: {md}"
17745        );
17746        let rt = markdown_to_adf(&md).unwrap();
17747        assert_eq!(rt.content[0].local_id.as_deref(), Some("exp-002"));
17748    }
17749
17750    #[test]
17751    fn expand_localid_stripped() {
17752        let adf_json = r#"{"version":1,"type":"doc","content":[
17753          {"type":"expand","attrs":{"localId":"exp-001","title":"X"},"content":[
17754            {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17755          ]}
17756        ]}"#;
17757        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17758        let opts = RenderOptions {
17759            strip_local_ids: true,
17760        };
17761        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
17762        assert!(!md.contains("localId"), "localId should be stripped: {md}");
17763        assert!(
17764            md.contains(":::expand{title=\"X\"}"),
17765            "title should remain: {md}"
17766        );
17767    }
17768
17769    // ── Issue #444: top-level localId and parameters on expand ──
17770
17771    #[test]
17772    fn expand_top_level_localid_roundtrip() {
17773        // localId as a top-level field (not inside attrs) should survive round-trip
17774        let adf_json = r#"{"version":1,"type":"doc","content":[
17775          {"type":"expand","attrs":{"title":"My Section"},"localId":"abc-123","content":[
17776            {"type":"paragraph","content":[{"type":"text","text":"hello"}]}
17777          ]}
17778        ]}"#;
17779        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17780        assert_eq!(doc.content[0].local_id.as_deref(), Some("abc-123"));
17781        let md = adf_to_markdown(&doc).unwrap();
17782        assert!(
17783            md.contains("localId=abc-123"),
17784            "JFM should contain localId: {md}"
17785        );
17786        let rt = markdown_to_adf(&md).unwrap();
17787        let expand = &rt.content[0];
17788        assert_eq!(expand.node_type, "expand");
17789        assert_eq!(expand.local_id.as_deref(), Some("abc-123"));
17790        assert_eq!(
17791            expand.attrs.as_ref().unwrap()["title"],
17792            "My Section",
17793            "title should survive round-trip"
17794        );
17795    }
17796
17797    #[test]
17798    fn expand_parameters_roundtrip() {
17799        // parameters (macroMetadata) should survive round-trip
17800        let adf_json = r#"{"version":1,"type":"doc","content":[
17801          {"type":"expand","attrs":{"title":"Props"},"parameters":{"macroMetadata":{"macroId":{"value":"m-001"},"schemaVersion":{"value":"1"}}},"content":[
17802            {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17803          ]}
17804        ]}"#;
17805        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17806        assert!(doc.content[0].parameters.is_some());
17807        let md = adf_to_markdown(&doc).unwrap();
17808        assert!(md.contains("params="), "JFM should contain params: {md}");
17809        let rt = markdown_to_adf(&md).unwrap();
17810        let expand = &rt.content[0];
17811        let params = expand
17812            .parameters
17813            .as_ref()
17814            .expect("parameters should survive round-trip");
17815        assert_eq!(params["macroMetadata"]["macroId"]["value"], "m-001");
17816        assert_eq!(params["macroMetadata"]["schemaVersion"]["value"], "1");
17817    }
17818
17819    #[test]
17820    fn expand_localid_and_parameters_roundtrip() {
17821        // Issue #444: both localId and parameters on expand should survive round-trip
17822        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"}]}]}]}"#;
17823        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17824        let md = adf_to_markdown(&doc).unwrap();
17825        let rt = markdown_to_adf(&md).unwrap();
17826        let expand = &rt.content[0];
17827        assert_eq!(expand.node_type, "expand");
17828        assert_eq!(expand.local_id.as_deref(), Some("abc-123"));
17829        assert_eq!(expand.attrs.as_ref().unwrap()["title"], "My Section");
17830        let params = expand
17831            .parameters
17832            .as_ref()
17833            .expect("parameters should survive");
17834        assert_eq!(params["macroMetadata"]["macroId"]["value"], "macro-001");
17835        assert_eq!(params["macroMetadata"]["title"], "Page Properties");
17836    }
17837
17838    #[test]
17839    fn nested_expand_top_level_localid_and_parameters_roundtrip() {
17840        let adf_json = r#"{"version":1,"type":"doc","content":[
17841          {"type":"nestedExpand","attrs":{"title":"Nested"},"localId":"ne-100","parameters":{"macroMetadata":{"macroId":{"value":"nm-001"}}},"content":[
17842            {"type":"paragraph","content":[{"type":"text","text":"inner"}]}
17843          ]}
17844        ]}"#;
17845        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17846        let md = adf_to_markdown(&doc).unwrap();
17847        assert!(
17848            md.contains(":::nested-expand{"),
17849            "should use nested-expand: {md}"
17850        );
17851        assert!(md.contains("localId=ne-100"), "should have localId: {md}");
17852        assert!(md.contains("params="), "should have params: {md}");
17853        let rt = markdown_to_adf(&md).unwrap();
17854        let ne = &rt.content[0];
17855        assert_eq!(ne.node_type, "nestedExpand");
17856        assert_eq!(ne.local_id.as_deref(), Some("ne-100"));
17857        assert_eq!(
17858            ne.parameters.as_ref().unwrap()["macroMetadata"]["macroId"]["value"],
17859            "nm-001"
17860        );
17861    }
17862
17863    #[test]
17864    fn expand_top_level_localid_stripped() {
17865        // strip_local_ids should strip top-level localId too
17866        let adf_json = r#"{"version":1,"type":"doc","content":[
17867          {"type":"expand","attrs":{"title":"X"},"localId":"exp-strip","content":[
17868            {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17869          ]}
17870        ]}"#;
17871        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17872        let opts = RenderOptions {
17873            strip_local_ids: true,
17874        };
17875        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
17876        assert!(!md.contains("localId"), "localId should be stripped: {md}");
17877        assert!(
17878            md.contains(":::expand{title=\"X\"}"),
17879            "title should remain: {md}"
17880        );
17881    }
17882
17883    #[test]
17884    fn expand_parameters_without_localid() {
17885        // parameters without localId should work
17886        let adf_json = r#"{"version":1,"type":"doc","content":[
17887          {"type":"expand","attrs":{"title":"P"},"parameters":{"macroMetadata":{"macroId":{"value":"solo"}}},"content":[
17888            {"type":"paragraph","content":[{"type":"text","text":"data"}]}
17889          ]}
17890        ]}"#;
17891        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17892        let md = adf_to_markdown(&doc).unwrap();
17893        assert!(!md.contains("localId"), "no localId: {md}");
17894        assert!(md.contains("params="), "has params: {md}");
17895        let rt = markdown_to_adf(&md).unwrap();
17896        assert!(rt.content[0].local_id.is_none());
17897        assert_eq!(
17898            rt.content[0].parameters.as_ref().unwrap()["macroMetadata"]["macroId"]["value"],
17899            "solo"
17900        );
17901    }
17902
17903    #[test]
17904    fn expand_localid_without_parameters() {
17905        // top-level localId without parameters should work
17906        let adf_json = r#"{"version":1,"type":"doc","content":[
17907          {"type":"expand","attrs":{"title":"L"},"localId":"lid-only","content":[
17908            {"type":"paragraph","content":[{"type":"text","text":"txt"}]}
17909          ]}
17910        ]}"#;
17911        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17912        let md = adf_to_markdown(&doc).unwrap();
17913        assert!(md.contains("localId=lid-only"), "has localId: {md}");
17914        assert!(!md.contains("params="), "no params: {md}");
17915        let rt = markdown_to_adf(&md).unwrap();
17916        assert_eq!(rt.content[0].local_id.as_deref(), Some("lid-only"));
17917        assert!(rt.content[0].parameters.is_none());
17918    }
17919
17920    #[test]
17921    fn nested_panel_inside_panel() {
17922        let md = ":::panel{type=info}\n:::panel{type=warning}\nInner warning\n:::\n:::";
17923        let adf = markdown_to_adf(md).unwrap();
17924
17925        // Outer panel should exist
17926        assert_eq!(adf.content.len(), 1);
17927        assert_eq!(adf.content[0].node_type, "panel");
17928
17929        // Outer panel should contain an inner panel (not have it truncated)
17930        let panel_content = adf.content[0].content.as_ref().unwrap();
17931        assert!(
17932            panel_content.iter().any(|n| n.node_type == "panel"),
17933            "Outer panel should contain an inner panel, got: {:?}",
17934            panel_content
17935                .iter()
17936                .map(|n| &n.node_type)
17937                .collect::<Vec<_>>()
17938        );
17939    }
17940
17941    #[test]
17942    fn content_after_directive_table_is_preserved() {
17943        // Issue #361: content after a ::::table block was silently dropped
17944        let md = "\
17945## Before table
17946
17947::::table{layout=default}
17948:::tr
17949:::th{}
17950Cell
17951:::
17952:::
17953::::
17954
17955## After table
17956
17957Paragraph after.";
17958        let adf = markdown_to_adf(md).unwrap();
17959        let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
17960        assert_eq!(
17961            types,
17962            vec!["heading", "table", "heading", "paragraph"],
17963            "Content after table was dropped: got {types:?}"
17964        );
17965    }
17966
17967    #[test]
17968    fn paragraph_after_directive_table_is_preserved() {
17969        // Issue #361: minimal reproducer — paragraph after table
17970        let md = "\
17971::::table{layout=default}
17972:::tr
17973:::th{}
17974Header
17975:::
17976:::
17977::::
17978
17979Just a paragraph.";
17980        let adf = markdown_to_adf(md).unwrap();
17981        let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
17982        assert_eq!(
17983            types,
17984            vec!["table", "paragraph"],
17985            "Paragraph after table was dropped: got {types:?}"
17986        );
17987    }
17988
17989    #[test]
17990    fn extension_after_directive_table_is_preserved() {
17991        // Issue #361: extension after table
17992        let md = "\
17993::::table{layout=default}
17994:::tr
17995:::th{}
17996Header
17997:::
17998:::
17999::::
18000
18001::extension{type=com.atlassian.confluence.macro.core key=toc}";
18002        let adf = markdown_to_adf(md).unwrap();
18003        let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
18004        assert_eq!(
18005            types,
18006            vec!["table", "extension"],
18007            "Extension after table was dropped: got {types:?}"
18008        );
18009    }
18010
18011    #[test]
18012    fn multiple_blocks_after_directive_table() {
18013        // Issue #361: multiple blocks after table, including another table
18014        let md = "\
18015## Heading 1
18016
18017::::table{layout=default}
18018:::tr
18019:::td{}
18020A
18021:::
18022:::td{}
18023B
18024:::
18025:::
18026::::
18027
18028## Heading 2
18029
18030Some text.
18031
18032---
18033
18034::::table{layout=default}
18035:::tr
18036:::th{}
18037C
18038:::
18039:::
18040::::
18041
18042## Heading 3";
18043        let adf = markdown_to_adf(md).unwrap();
18044        let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
18045        assert_eq!(
18046            types,
18047            vec![
18048                "heading",
18049                "table",
18050                "heading",
18051                "paragraph",
18052                "rule",
18053                "table",
18054                "heading"
18055            ],
18056            "Content after tables was dropped: got {types:?}"
18057        );
18058    }
18059
18060    // ── Table caption tests (issue #382) ────────────────────────────
18061
18062    #[test]
18063    fn adf_table_caption_to_markdown() {
18064        let doc = AdfDocument {
18065            version: 1,
18066            doc_type: "doc".to_string(),
18067            content: vec![AdfNode::table(vec![
18068                AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
18069                    AdfNode::text("cell"),
18070                ])])]),
18071                AdfNode::caption(vec![AdfNode::text("Table caption")]),
18072            ])],
18073        };
18074        let md = adf_to_markdown(&doc).unwrap();
18075        assert!(
18076            md.contains("::::table"),
18077            "table with caption must use directive form"
18078        );
18079        assert!(
18080            md.contains(":::caption"),
18081            "caption directive missing, got: {md}"
18082        );
18083        assert!(
18084            md.contains("Table caption"),
18085            "caption text missing, got: {md}"
18086        );
18087    }
18088
18089    #[test]
18090    fn directive_table_caption_parses() {
18091        let md = "::::table\n:::tr\n:::td\ncell\n:::\n:::\n:::caption\nTable caption\n:::\n::::\n";
18092        let doc = markdown_to_adf(md).unwrap();
18093        let table = &doc.content[0];
18094        assert_eq!(table.node_type, "table");
18095        let children = table.content.as_ref().unwrap();
18096        assert_eq!(children.len(), 2, "expected row + caption");
18097        assert_eq!(children[0].node_type, "tableRow");
18098        assert_eq!(children[1].node_type, "caption");
18099        let caption_content = children[1].content.as_ref().unwrap();
18100        assert_eq!(caption_content[0].text.as_deref(), Some("Table caption"));
18101    }
18102
18103    #[test]
18104    fn table_caption_round_trip_from_adf_json() {
18105        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
18106          {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
18107          {"type":"caption","content":[{"type":"text","text":"Table caption"}]}
18108        ]}]}"#;
18109        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18110        let md = adf_to_markdown(&doc).unwrap();
18111        assert!(md.contains("Table caption"), "caption text lost in ADF→JFM");
18112        let round_tripped = markdown_to_adf(&md).unwrap();
18113        let children = round_tripped.content[0].content.as_ref().unwrap();
18114        let caption = children.iter().find(|n| n.node_type == "caption");
18115        assert!(caption.is_some(), "caption lost on round-trip");
18116        let caption_text = caption.unwrap().content.as_ref().unwrap();
18117        assert_eq!(caption_text[0].text.as_deref(), Some("Table caption"));
18118    }
18119
18120    #[test]
18121    fn table_caption_with_inline_marks_round_trips() {
18122        let doc = AdfDocument {
18123            version: 1,
18124            doc_type: "doc".to_string(),
18125            content: vec![AdfNode::table(vec![
18126                AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
18127                    AdfNode::text("data"),
18128                ])])]),
18129                AdfNode::caption(vec![
18130                    AdfNode::text("Caption with "),
18131                    AdfNode::text_with_marks("bold", vec![AdfMark::strong()]),
18132                ]),
18133            ])],
18134        };
18135        let md = adf_to_markdown(&doc).unwrap();
18136        assert!(md.contains("**bold**"), "bold mark missing in caption");
18137        let round_tripped = markdown_to_adf(&md).unwrap();
18138        let caption = round_tripped.content[0]
18139            .content
18140            .as_ref()
18141            .unwrap()
18142            .iter()
18143            .find(|n| n.node_type == "caption")
18144            .expect("caption node missing after round-trip");
18145        let inlines = caption.content.as_ref().unwrap();
18146        let bold_node = inlines.iter().find(|n| {
18147            n.marks
18148                .as_ref()
18149                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
18150        });
18151        assert!(bold_node.is_some(), "bold mark lost in caption round-trip");
18152    }
18153
18154    // ── table caption localId tests (issue #524) ──────────────────────
18155
18156    #[test]
18157    fn table_caption_localid_roundtrip() {
18158        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
18159          {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
18160          {"type":"caption","attrs":{"localId":"abcdef123456"},"content":[{"type":"text","text":"Table with localId"}]}
18161        ]}]}"#;
18162        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18163        let md = adf_to_markdown(&doc).unwrap();
18164        assert!(
18165            md.contains("localId=abcdef123456"),
18166            "table caption localId should appear in markdown: {md}"
18167        );
18168        let rt = markdown_to_adf(&md).unwrap();
18169        let caption = rt.content[0]
18170            .content
18171            .as_ref()
18172            .unwrap()
18173            .iter()
18174            .find(|n| n.node_type == "caption")
18175            .expect("caption should survive round-trip");
18176        assert_eq!(
18177            caption.attrs.as_ref().unwrap()["localId"],
18178            "abcdef123456",
18179            "table caption localId should round-trip"
18180        );
18181    }
18182
18183    #[test]
18184    fn table_caption_without_localid_unchanged() {
18185        let md = "::::table\n:::tr\n:::td\ncell\n:::\n:::\n:::caption\nPlain caption\n:::\n::::\n";
18186        let doc = markdown_to_adf(md).unwrap();
18187        let caption = doc.content[0]
18188            .content
18189            .as_ref()
18190            .unwrap()
18191            .iter()
18192            .find(|n| n.node_type == "caption")
18193            .unwrap();
18194        assert!(
18195            caption.attrs.is_none(),
18196            "table caption without localId should not gain attrs"
18197        );
18198        let md2 = adf_to_markdown(&doc).unwrap();
18199        assert!(!md2.contains("localId"), "no localId should appear: {md2}");
18200    }
18201
18202    #[test]
18203    fn table_caption_localid_stripped_when_option_set() {
18204        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
18205          {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
18206          {"type":"caption","attrs":{"localId":"abcdef123456"},"content":[{"type":"text","text":"Stripped"}]}
18207        ]}]}"#;
18208        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18209        let opts = RenderOptions {
18210            strip_local_ids: true,
18211            ..Default::default()
18212        };
18213        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
18214        assert!(
18215            !md.contains("localId"),
18216            "table caption localId should be stripped: {md}"
18217        );
18218    }
18219
18220    #[test]
18221    #[test]
18222    fn tablecell_empty_attrs_preserved_on_roundtrip() {
18223        // Issue #385: tableCell with empty attrs:{} dropped on round-trip
18224        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"}]}]}]}]}]}"#;
18225        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18226        let md = adf_to_markdown(&doc).unwrap();
18227        let round_tripped = markdown_to_adf(&md).unwrap();
18228        let rows = round_tripped.content[0].content.as_ref().unwrap();
18229        let cell = &rows[0].content.as_ref().unwrap()[0];
18230        assert!(
18231            cell.attrs.is_some(),
18232            "tableCell attrs should be preserved, got None"
18233        );
18234        assert_eq!(
18235            cell.attrs.as_ref().unwrap(),
18236            &serde_json::json!({}),
18237            "tableCell attrs should be an empty object"
18238        );
18239    }
18240
18241    #[test]
18242    fn tablecell_empty_attrs_serialized_in_json() {
18243        // Issue #385: ensure the serialized JSON includes "attrs":{}
18244        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"}]}]}]}]}]}"#;
18245        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18246        let md = adf_to_markdown(&doc).unwrap();
18247        let round_tripped = markdown_to_adf(&md).unwrap();
18248        let json = serde_json::to_string(&round_tripped).unwrap();
18249        assert!(
18250            json.contains(r#""attrs":{}"#),
18251            "serialized JSON should contain \"attrs\":{{}}, got: {json}"
18252        );
18253    }
18254
18255    #[test]
18256    fn tablecell_empty_attrs_renders_braces_in_markdown() {
18257        // Issue #385: tableCell with empty attrs should render {} prefix in pipe tables
18258        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"}]}]}]}]}]}"#;
18259        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18260        let md = adf_to_markdown(&doc).unwrap();
18261        // Cell with attrs:{} should have {} prefix, cell without attrs should not
18262        assert!(
18263            md.contains("{} hello"),
18264            "cell with empty attrs should render '{{}} hello', got: {md}"
18265        );
18266        assert!(
18267            !md.contains("{} world"),
18268            "cell without attrs should not render '{{}}', got: {md}"
18269        );
18270    }
18271
18272    #[test]
18273    fn tablecell_no_attrs_unchanged_on_roundtrip() {
18274        // Ensure tableCell without attrs stays without attrs
18275        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"}]}]}]}]}]}"#;
18276        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18277        let md = adf_to_markdown(&doc).unwrap();
18278        let round_tripped = markdown_to_adf(&md).unwrap();
18279        let rows = round_tripped.content[0].content.as_ref().unwrap();
18280        let cell = &rows[0].content.as_ref().unwrap()[0];
18281        assert!(
18282            cell.attrs.is_none(),
18283            "tableCell without attrs should stay None, got: {:?}",
18284            cell.attrs
18285        );
18286    }
18287
18288    #[test]
18289    fn tablecell_nonempty_attrs_preserved_on_roundtrip() {
18290        // Ensure tableCell with non-empty attrs still works
18291        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"}]}]}]}]}]}"##;
18292        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18293        let md = adf_to_markdown(&doc).unwrap();
18294        let round_tripped = markdown_to_adf(&md).unwrap();
18295        let rows = round_tripped.content[0].content.as_ref().unwrap();
18296        let cell = &rows[1].content.as_ref().unwrap()[0];
18297        let attrs = cell.attrs.as_ref().unwrap();
18298        assert_eq!(attrs["background"], "#DEEBFF");
18299        assert_eq!(attrs["colspan"], 2);
18300    }
18301
18302    #[test]
18303    fn pipe_table_not_used_when_caption_present() {
18304        let doc = AdfDocument {
18305            version: 1,
18306            doc_type: "doc".to_string(),
18307            content: vec![AdfNode::table(vec![
18308                AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
18309                    AdfNode::text("H"),
18310                ])])]),
18311                AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
18312                    AdfNode::text("D"),
18313                ])])]),
18314                AdfNode::caption(vec![AdfNode::text("cap")]),
18315            ])],
18316        };
18317        let md = adf_to_markdown(&doc).unwrap();
18318        assert!(
18319            md.contains("::::table"),
18320            "pipe syntax should not be used when caption is present"
18321        );
18322    }
18323
18324    // ── Issue #402: ordered-list-like text in list item hardBreak ──
18325
18326    #[test]
18327    fn hardbreak_with_ordered_marker_in_bullet_item_roundtrips() {
18328        // Issue #402: text starting with "2. " after a hardBreak inside a
18329        // bullet list item must not be re-parsed as a new ordered list.
18330        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18331          {"type":"listItem","content":[{"type":"paragraph","content":[
18332            {"type":"text","text":"1. First item"},
18333            {"type":"hardBreak"},
18334            {"type":"text","text":"2. Honouring existing commitments"}
18335          ]}]}
18336        ]}]}"#;
18337        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18338        let md = adf_to_markdown(&doc).unwrap();
18339
18340        // The continuation line must be indented so it stays within the list item.
18341        assert!(
18342            md.contains("  2. Honouring"),
18343            "Continuation line should be indented, got:\n{md}"
18344        );
18345
18346        // Round-trip back to ADF
18347        let rt = markdown_to_adf(&md).unwrap();
18348        let list = &rt.content[0];
18349        assert_eq!(list.node_type, "bulletList");
18350        let items = list.content.as_ref().unwrap();
18351        assert_eq!(
18352            items.len(),
18353            1,
18354            "Should be one list item, got {}",
18355            items.len()
18356        );
18357
18358        let para = &items[0].content.as_ref().unwrap()[0];
18359        let inlines = para.content.as_ref().unwrap();
18360        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18361        assert_eq!(
18362            types,
18363            vec!["text", "hardBreak", "text"],
18364            "Expected text+hardBreak+text, got {types:?}"
18365        );
18366        assert_eq!(
18367            inlines[2].text.as_deref().unwrap(),
18368            "2. Honouring existing commitments"
18369        );
18370    }
18371
18372    #[test]
18373    fn hardbreak_with_ordered_marker_in_ordered_item_roundtrips() {
18374        // Same as above but inside an ordered list.
18375        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
18376          {"type":"listItem","content":[{"type":"paragraph","content":[
18377            {"type":"text","text":"Introduction  "},
18378            {"type":"hardBreak"},
18379            {"type":"text","text":"3. Third point"}
18380          ]}]}
18381        ]}]}"#;
18382        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18383        let md = adf_to_markdown(&doc).unwrap();
18384        let rt = markdown_to_adf(&md).unwrap();
18385
18386        let list = &rt.content[0];
18387        assert_eq!(list.node_type, "orderedList");
18388        let items = list.content.as_ref().unwrap();
18389        assert_eq!(items.len(), 1);
18390
18391        let para = &items[0].content.as_ref().unwrap()[0];
18392        let inlines = para.content.as_ref().unwrap();
18393        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18394        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18395        assert_eq!(inlines[2].text.as_deref().unwrap(), "3. Third point");
18396    }
18397
18398    #[test]
18399    fn hardbreak_with_bullet_marker_in_bullet_item_roundtrips() {
18400        // Text starting with "- " after a hardBreak must not become a nested bullet list.
18401        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18402          {"type":"listItem","content":[{"type":"paragraph","content":[
18403            {"type":"text","text":"Header  "},
18404            {"type":"hardBreak"},
18405            {"type":"text","text":"- not a sub-item"}
18406          ]}]}
18407        ]}]}"#;
18408        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18409        let md = adf_to_markdown(&doc).unwrap();
18410        let rt = markdown_to_adf(&md).unwrap();
18411
18412        let list = &rt.content[0];
18413        assert_eq!(list.node_type, "bulletList");
18414        let items = list.content.as_ref().unwrap();
18415        assert_eq!(
18416            items.len(),
18417            1,
18418            "Should be one list item, not {}",
18419            items.len()
18420        );
18421
18422        let para = &items[0].content.as_ref().unwrap()[0];
18423        let inlines = para.content.as_ref().unwrap();
18424        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18425        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18426        assert_eq!(inlines[2].text.as_deref().unwrap(), "- not a sub-item");
18427    }
18428
18429    #[test]
18430    fn hardbreak_continuation_followed_by_sub_list() {
18431        // A hardBreak continuation line followed by a real sub-list.
18432        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18433          {"type":"listItem","content":[
18434            {"type":"paragraph","content":[
18435              {"type":"text","text":"Main item  "},
18436              {"type":"hardBreak"},
18437              {"type":"text","text":"continued here"}
18438            ]},
18439            {"type":"bulletList","content":[
18440              {"type":"listItem","content":[{"type":"paragraph","content":[
18441                {"type":"text","text":"sub-item"}
18442              ]}]}
18443            ]}
18444          ]}
18445        ]}]}"#;
18446        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18447        let md = adf_to_markdown(&doc).unwrap();
18448        let rt = markdown_to_adf(&md).unwrap();
18449
18450        let list = &rt.content[0];
18451        let items = list.content.as_ref().unwrap();
18452        assert_eq!(items.len(), 1);
18453
18454        let item_content = items[0].content.as_ref().unwrap();
18455        assert_eq!(item_content.len(), 2, "Expected paragraph + nested list");
18456        assert_eq!(item_content[0].node_type, "paragraph");
18457        assert_eq!(item_content[1].node_type, "bulletList");
18458
18459        // Check the paragraph has hardBreak
18460        let inlines = item_content[0].content.as_ref().unwrap();
18461        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18462        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18463    }
18464
18465    #[test]
18466    fn multiple_hardbreaks_with_numbered_text_roundtrip() {
18467        // Multiple hardBreaks where each continuation resembles an ordered list.
18468        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18469          {"type":"listItem","content":[{"type":"paragraph","content":[
18470            {"type":"text","text":"Preamble  "},
18471            {"type":"hardBreak"},
18472            {"type":"text","text":"1. Alpha  "},
18473            {"type":"hardBreak"},
18474            {"type":"text","text":"2. Bravo"}
18475          ]}]}
18476        ]}]}"#;
18477        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18478        let md = adf_to_markdown(&doc).unwrap();
18479        let rt = markdown_to_adf(&md).unwrap();
18480
18481        let items = rt.content[0].content.as_ref().unwrap();
18482        assert_eq!(items.len(), 1);
18483
18484        let inlines = items[0].content.as_ref().unwrap()[0]
18485            .content
18486            .as_ref()
18487            .unwrap();
18488        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18489        assert_eq!(
18490            types,
18491            vec!["text", "hardBreak", "text", "hardBreak", "text"]
18492        );
18493    }
18494
18495    #[test]
18496    fn trailing_hardbreak_in_bullet_item_roundtrips() {
18497        // A hardBreak as the last inline node with no text after it.
18498        // Exercises the `break` path in the continuation loop and the
18499        // empty-line rendering branch.
18500        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18501          {"type":"listItem","content":[{"type":"paragraph","content":[
18502            {"type":"text","text":"ends with break"},
18503            {"type":"hardBreak"}
18504          ]}]}
18505        ]}]}"#;
18506        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18507        let md = adf_to_markdown(&doc).unwrap();
18508        let rt = markdown_to_adf(&md).unwrap();
18509
18510        let list = &rt.content[0];
18511        assert_eq!(list.node_type, "bulletList");
18512        let inlines = list.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0]
18513            .content
18514            .as_ref()
18515            .unwrap();
18516        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18517        assert_eq!(types, vec!["text", "hardBreak"]);
18518    }
18519
18520    #[test]
18521    fn trailing_hardbreak_in_ordered_item_roundtrips() {
18522        // Same as above but in an ordered list, covering the ordered-list
18523        // continuation `break` path.
18524        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
18525          {"type":"listItem","content":[{"type":"paragraph","content":[
18526            {"type":"text","text":"ends with break"},
18527            {"type":"hardBreak"}
18528          ]}]}
18529        ]}]}"#;
18530        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18531        let md = adf_to_markdown(&doc).unwrap();
18532        let rt = markdown_to_adf(&md).unwrap();
18533
18534        let list = &rt.content[0];
18535        assert_eq!(list.node_type, "orderedList");
18536        let inlines = list.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0]
18537            .content
18538            .as_ref()
18539            .unwrap();
18540        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18541        assert_eq!(types, vec!["text", "hardBreak"]);
18542    }
18543
18544    #[test]
18545    fn trailing_space_hardbreak_continuation_in_bullet_item() {
18546        // Exercises the `ends_with("  ")` path in `has_trailing_hard_break`
18547        // by parsing hand-written markdown that uses trailing-space style
18548        // hardBreaks instead of backslash style.
18549        let md = "- first line  \n  2. continued\n";
18550        let doc = markdown_to_adf(md).unwrap();
18551
18552        let list = &doc.content[0];
18553        assert_eq!(list.node_type, "bulletList");
18554        let items = list.content.as_ref().unwrap();
18555        assert_eq!(
18556            items.len(),
18557            1,
18558            "Should be one list item, got {}",
18559            items.len()
18560        );
18561
18562        let para = &items[0].content.as_ref().unwrap()[0];
18563        let inlines = para.content.as_ref().unwrap();
18564        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18565        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18566        assert_eq!(inlines[2].text.as_deref().unwrap(), "2. continued");
18567    }
18568
18569    #[test]
18570    fn trailing_space_hardbreak_continuation_in_ordered_item() {
18571        // Same as above but for ordered list, exercising the trailing-space
18572        // path in the ordered-list continuation loop.
18573        let md = "1. first line  \n  - continued\n";
18574        let doc = markdown_to_adf(md).unwrap();
18575
18576        let list = &doc.content[0];
18577        assert_eq!(list.node_type, "orderedList");
18578        let items = list.content.as_ref().unwrap();
18579        assert_eq!(
18580            items.len(),
18581            1,
18582            "Should be one list item, got {}",
18583            items.len()
18584        );
18585
18586        let para = &items[0].content.as_ref().unwrap()[0];
18587        let inlines = para.content.as_ref().unwrap();
18588        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18589        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18590        assert_eq!(inlines[2].text.as_deref().unwrap(), "- continued");
18591    }
18592
18593    #[test]
18594    fn multi_paragraph_list_item_with_ordered_marker_roundtrips() {
18595        // Issue #402 comment: a listItem with a second paragraph starting
18596        // with "2. " must not become a separate orderedList.
18597        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18598          {"type":"listItem","content":[
18599            {"type":"paragraph","content":[{"type":"text","text":"some preamble"}]},
18600            {"type":"paragraph","content":[{"type":"text","text":"2. Honouring existing commitments"}]}
18601          ]}
18602        ]}]}"#;
18603        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18604        let md = adf_to_markdown(&doc).unwrap();
18605        let rt = markdown_to_adf(&md).unwrap();
18606
18607        assert_eq!(rt.content.len(), 1, "Should be one top-level block");
18608        let list = &rt.content[0];
18609        assert_eq!(list.node_type, "bulletList");
18610        let items = list.content.as_ref().unwrap();
18611        assert_eq!(items.len(), 1);
18612        let item_content = items[0].content.as_ref().unwrap();
18613        assert_eq!(
18614            item_content.len(),
18615            2,
18616            "Expected 2 paragraphs inside the list item, got {}",
18617            item_content.len()
18618        );
18619        assert_eq!(item_content[0].node_type, "paragraph");
18620        assert_eq!(item_content[1].node_type, "paragraph");
18621        let text = item_content[1].content.as_ref().unwrap()[0]
18622            .text
18623            .as_deref()
18624            .unwrap();
18625        assert_eq!(text, "2. Honouring existing commitments");
18626    }
18627
18628    #[test]
18629    fn multi_paragraph_list_item_with_bullet_marker_roundtrips() {
18630        // Paragraph starting with "- " inside a list item.
18631        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18632          {"type":"listItem","content":[
18633            {"type":"paragraph","content":[{"type":"text","text":"preamble"}]},
18634            {"type":"paragraph","content":[{"type":"text","text":"- not a sub-item"}]}
18635          ]}
18636        ]}]}"#;
18637        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18638        let md = adf_to_markdown(&doc).unwrap();
18639        let rt = markdown_to_adf(&md).unwrap();
18640
18641        let items = rt.content[0].content.as_ref().unwrap();
18642        assert_eq!(items.len(), 1);
18643        let item_content = items[0].content.as_ref().unwrap();
18644        assert_eq!(item_content.len(), 2);
18645        assert_eq!(item_content[1].node_type, "paragraph");
18646        let text = item_content[1].content.as_ref().unwrap()[0]
18647            .text
18648            .as_deref()
18649            .unwrap();
18650        assert_eq!(text, "- not a sub-item");
18651    }
18652
18653    #[test]
18654    fn backslash_escape_in_inline_text() {
18655        // Verify that `\. ` is unescaped to `. ` in inline parsing.
18656        let nodes = parse_inline(r"2\. text");
18657        assert_eq!(nodes.len(), 1, "Should be one text node");
18658        assert_eq!(nodes[0].text.as_deref().unwrap(), "2. text");
18659    }
18660
18661    #[test]
18662    fn escape_list_marker_ordered() {
18663        assert_eq!(escape_list_marker("2. text"), r"2\. text");
18664        assert_eq!(escape_list_marker("10. tenth"), r"10\. tenth");
18665    }
18666
18667    #[test]
18668    fn escape_list_marker_bullet() {
18669        assert_eq!(escape_list_marker("- text"), r"\- text");
18670        assert_eq!(escape_list_marker("* text"), r"\* text");
18671        assert_eq!(escape_list_marker("+ text"), r"\+ text");
18672    }
18673
18674    #[test]
18675    fn escape_list_marker_plain() {
18676        assert_eq!(escape_list_marker("plain text"), "plain text");
18677        assert_eq!(escape_list_marker("no. marker"), "no. marker");
18678    }
18679
18680    #[test]
18681    fn escape_emoji_shortcodes_basic() {
18682        assert_eq!(escape_emoji_shortcodes(":fire:"), r"\:fire:");
18683        assert_eq!(
18684            escape_emoji_shortcodes("hello :wave: world"),
18685            r"hello \:wave: world"
18686        );
18687    }
18688
18689    #[test]
18690    fn escape_emoji_shortcodes_double_colon() {
18691        // Only the colon that starts `:Active:` needs escaping
18692        assert_eq!(
18693            escape_emoji_shortcodes("Status::Active::Running"),
18694            r"Status:\:Active::Running"
18695        );
18696    }
18697
18698    #[test]
18699    fn escape_emoji_shortcodes_no_match() {
18700        // Lone colons, numeric-only between colons like 10:30
18701        assert_eq!(escape_emoji_shortcodes("Time is 10:30"), "Time is 10:30");
18702        assert_eq!(escape_emoji_shortcodes("no colons here"), "no colons here");
18703        assert_eq!(escape_emoji_shortcodes("trailing:"), "trailing:");
18704        assert_eq!(escape_emoji_shortcodes(":"), ":");
18705    }
18706
18707    #[test]
18708    fn escape_emoji_shortcodes_mixed() {
18709        assert_eq!(
18710            escape_emoji_shortcodes("Alert :fire: on pod:pod42"),
18711            r"Alert \:fire: on pod:pod42"
18712        );
18713    }
18714
18715    #[test]
18716    fn escape_emoji_shortcodes_unicode() {
18717        // Issue #552: Unicode alphanumeric chars must be escaped to match
18718        // `try_parse_emoji_shortcode`, which uses `is_alphanumeric` (not the
18719        // ASCII-only variant).  Without this, `:Café:` rendered un-escaped
18720        // would be re-parsed as an emoji on round-trip.
18721        assert_eq!(escape_emoji_shortcodes(":Café:"), r"\:Café:");
18722        assert_eq!(escape_emoji_shortcodes(":über:"), r"\:über:");
18723        assert_eq!(escape_emoji_shortcodes(":配置:"), r"\:配置:");
18724        assert_eq!(
18725            escape_emoji_shortcodes("ZBC::配置::Production"),
18726            r"ZBC:\:配置::Production"
18727        );
18728    }
18729
18730    #[test]
18731    fn escape_emoji_shortcodes_mixed_script_name() {
18732        // Issue #552: A name that mixes ASCII and Unicode alphanumerics is
18733        // still a single valid shortcode under `is_alphanumeric`.
18734        assert_eq!(escape_emoji_shortcodes(":abc配置:"), r"\:abc配置:");
18735        assert_eq!(escape_emoji_shortcodes(":配置abc:"), r"\:配置abc:");
18736    }
18737
18738    #[test]
18739    fn escape_emoji_shortcodes_unicode_followed_by_non_colon() {
18740        // `:Café world:` — `Café` is alphanumeric but the terminator is a
18741        // space, not `:`, so the `after + name_end < text.len()` path is
18742        // exercised but the final `== b':'` check bails out and nothing
18743        // gets escaped.  Guards the negative branch of the predicate.
18744        assert_eq!(escape_emoji_shortcodes(":Café world:"), ":Café world:");
18745    }
18746
18747    #[test]
18748    fn escape_emoji_shortcodes_name_runs_to_end() {
18749        // Colon followed by alphanumerics to end of string: `.find(...)` returns
18750        // `None`, so `name_end` falls back to the full remaining length via
18751        // `map_or`.  The `after + name_end < text.len()` check then fails and
18752        // nothing is escaped.  Exercises the `map_or` default branch for both
18753        // ASCII and Unicode names.
18754        assert_eq!(escape_emoji_shortcodes(":abc"), ":abc");
18755        assert_eq!(escape_emoji_shortcodes(":配置"), ":配置");
18756    }
18757
18758    #[test]
18759    fn unicode_shortcode_pattern_text_round_trips_as_text() {
18760        // Issue #552: A text node containing `:Café:` must round-trip as text,
18761        // not be split into text + emoji nodes.
18762        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18763          {"type":"text","text":"Visit :Café: today"}
18764        ]}]}"#;
18765        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18766
18767        let md = adf_to_markdown(&doc).unwrap();
18768        let round_tripped = markdown_to_adf(&md).unwrap();
18769        let content = round_tripped.content[0].content.as_ref().unwrap();
18770
18771        assert_eq!(
18772            content.len(),
18773            1,
18774            "should be a single text node, got: {content:?}"
18775        );
18776        assert_eq!(content[0].node_type, "text");
18777        assert_eq!(content[0].text.as_deref().unwrap(), "Visit :Café: today");
18778    }
18779
18780    #[test]
18781    fn unicode_double_colon_pattern_text_round_trips() {
18782        // Issue #552: `ZBC::配置::Production` should round-trip without splitting.
18783        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18784          {"type":"text","text":"Use ZBC::配置::Production for prod"}
18785        ]}]}"#;
18786        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18787
18788        let md = adf_to_markdown(&doc).unwrap();
18789        let round_tripped = markdown_to_adf(&md).unwrap();
18790        let content = round_tripped.content[0].content.as_ref().unwrap();
18791
18792        assert_eq!(
18793            content.len(),
18794            1,
18795            "should be a single text node, got: {content:?}"
18796        );
18797        assert_eq!(
18798            content[0].text.as_deref().unwrap(),
18799            "Use ZBC::配置::Production for prod"
18800        );
18801    }
18802
18803    #[test]
18804    fn merge_adjacent_text_nodes() {
18805        let mut nodes = vec![AdfNode::text("a"), AdfNode::text("b"), AdfNode::text("c")];
18806        merge_adjacent_text(&mut nodes);
18807        assert_eq!(nodes.len(), 1);
18808        assert_eq!(nodes[0].text.as_deref().unwrap(), "abc");
18809    }
18810
18811    // ── Issue #455: text after hardBreak in paragraph re-parsed as list ──
18812
18813    #[test]
18814    fn issue_455_paragraph_hardbreak_ordered_marker_roundtrips() {
18815        // Issue #455: "1. text" after a hardBreak in a paragraph must not
18816        // become an ordered list.
18817        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18818          {"type":"text","text":"Introduction: "},
18819          {"type":"hardBreak"},
18820          {"type":"text","text":"1. This text follows a hardBreak"}
18821        ]}]}"#;
18822        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18823        let md = adf_to_markdown(&doc).unwrap();
18824        let rt = markdown_to_adf(&md).unwrap();
18825
18826        assert_eq!(rt.content.len(), 1, "Should remain one block");
18827        assert_eq!(rt.content[0].node_type, "paragraph");
18828        let inlines = rt.content[0].content.as_ref().unwrap();
18829        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18830        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18831        assert_eq!(
18832            inlines[2].text.as_deref(),
18833            Some("1. This text follows a hardBreak")
18834        );
18835    }
18836
18837    #[test]
18838    fn issue_455_paragraph_hardbreak_bullet_marker_roundtrips() {
18839        // Issue #455 variant: "- text" after a hardBreak in a paragraph.
18840        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18841          {"type":"text","text":"Intro"},
18842          {"type":"hardBreak"},
18843          {"type":"text","text":"- not a list item"}
18844        ]}]}"#;
18845        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18846        let md = adf_to_markdown(&doc).unwrap();
18847        let rt = markdown_to_adf(&md).unwrap();
18848
18849        assert_eq!(rt.content.len(), 1);
18850        assert_eq!(rt.content[0].node_type, "paragraph");
18851        let inlines = rt.content[0].content.as_ref().unwrap();
18852        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18853        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18854        assert_eq!(inlines[2].text.as_deref(), Some("- not a list item"));
18855    }
18856
18857    #[test]
18858    fn issue_455_paragraph_hardbreak_heading_marker_roundtrips() {
18859        // Issue #455 variant: "# text" after a hardBreak in a paragraph.
18860        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18861          {"type":"text","text":"Intro"},
18862          {"type":"hardBreak"},
18863          {"type":"text","text":"# not a heading"}
18864        ]}]}"##;
18865        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18866        let md = adf_to_markdown(&doc).unwrap();
18867        let rt = markdown_to_adf(&md).unwrap();
18868
18869        assert_eq!(rt.content.len(), 1);
18870        assert_eq!(rt.content[0].node_type, "paragraph");
18871        let inlines = rt.content[0].content.as_ref().unwrap();
18872        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18873        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18874        assert_eq!(inlines[2].text.as_deref(), Some("# not a heading"));
18875    }
18876
18877    #[test]
18878    fn issue_455_paragraph_hardbreak_blockquote_marker_roundtrips() {
18879        // Issue #455 variant: "> text" after a hardBreak in a paragraph.
18880        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18881          {"type":"text","text":"Intro"},
18882          {"type":"hardBreak"},
18883          {"type":"text","text":"> not a blockquote"}
18884        ]}]}"#;
18885        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18886        let md = adf_to_markdown(&doc).unwrap();
18887        let rt = markdown_to_adf(&md).unwrap();
18888
18889        assert_eq!(rt.content.len(), 1);
18890        assert_eq!(rt.content[0].node_type, "paragraph");
18891        let inlines = rt.content[0].content.as_ref().unwrap();
18892        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18893        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18894        assert_eq!(inlines[2].text.as_deref(), Some("> not a blockquote"));
18895    }
18896
18897    #[test]
18898    fn issue_455_paragraph_multiple_hardbreaks_with_ordered_markers() {
18899        // Multiple hardBreaks in a paragraph, each followed by "N. text".
18900        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18901          {"type":"text","text":"Preamble"},
18902          {"type":"hardBreak"},
18903          {"type":"text","text":"1. First"},
18904          {"type":"hardBreak"},
18905          {"type":"text","text":"2. Second"},
18906          {"type":"hardBreak"},
18907          {"type":"text","text":"3. Third"}
18908        ]}]}"#;
18909        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18910        let md = adf_to_markdown(&doc).unwrap();
18911        let rt = markdown_to_adf(&md).unwrap();
18912
18913        assert_eq!(rt.content.len(), 1);
18914        assert_eq!(rt.content[0].node_type, "paragraph");
18915        let inlines = rt.content[0].content.as_ref().unwrap();
18916        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18917        assert_eq!(
18918            types,
18919            vec![
18920                "text",
18921                "hardBreak",
18922                "text",
18923                "hardBreak",
18924                "text",
18925                "hardBreak",
18926                "text"
18927            ]
18928        );
18929        assert_eq!(inlines[2].text.as_deref(), Some("1. First"));
18930        assert_eq!(inlines[4].text.as_deref(), Some("2. Second"));
18931        assert_eq!(inlines[6].text.as_deref(), Some("3. Third"));
18932    }
18933
18934    #[test]
18935    fn issue_455_paragraph_hardbreak_jfm_indentation() {
18936        // Verify that ADF→JFM output indents continuation lines.
18937        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18938          {"type":"text","text":"Intro"},
18939          {"type":"hardBreak"},
18940          {"type":"text","text":"1. continued"}
18941        ]}]}"#;
18942        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18943        let md = adf_to_markdown(&doc).unwrap();
18944        assert!(
18945            md.contains("Intro\\\n  1. continued"),
18946            "Continuation should be 2-space-indented, got: {md:?}"
18947        );
18948    }
18949
18950    #[test]
18951    fn issue_455_paragraph_hardbreak_from_jfm() {
18952        // Verify that JFM with 2-space-indented continuation is parsed
18953        // back as a single paragraph with hardBreak.
18954        let md = "Intro\\\n  1. This is continuation text\n";
18955        let doc = markdown_to_adf(md).unwrap();
18956
18957        assert_eq!(doc.content.len(), 1);
18958        assert_eq!(doc.content[0].node_type, "paragraph");
18959        let inlines = doc.content[0].content.as_ref().unwrap();
18960        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18961        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18962        assert_eq!(
18963            inlines[2].text.as_deref(),
18964            Some("1. This is continuation text")
18965        );
18966    }
18967
18968    #[test]
18969    fn issue_455_paragraph_starts_with_ordered_marker_and_hardbreak() {
18970        // Coverage: first line IS a list marker AND paragraph has hardBreaks.
18971        // Exercises the escape_list_marker path on the first line of a
18972        // multi-line paragraph buf in the rendering code.
18973        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18974          {"type":"text","text":"1. Starting with a number"},
18975          {"type":"hardBreak"},
18976          {"type":"text","text":"continuation after break"}
18977        ]}]}"#;
18978        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18979        let md = adf_to_markdown(&doc).unwrap();
18980        // First line should be escaped so it's not parsed as ordered list
18981        assert!(
18982            md.contains(r"1\. Starting with a number"),
18983            "First line should have escaped list marker, got: {md:?}"
18984        );
18985        let rt = markdown_to_adf(&md).unwrap();
18986
18987        assert_eq!(rt.content.len(), 1);
18988        assert_eq!(rt.content[0].node_type, "paragraph");
18989        let inlines = rt.content[0].content.as_ref().unwrap();
18990        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18991        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18992        assert_eq!(
18993            inlines[0].text.as_deref(),
18994            Some("1. Starting with a number")
18995        );
18996        assert_eq!(inlines[2].text.as_deref(), Some("continuation after break"));
18997    }
18998
18999    #[test]
19000    fn ordered_marker_paragraph_in_table_cell_roundtrips() {
19001        // Issue #402: paragraph with "2. " text inside a tableCell must
19002        // not be re-parsed as an ordered list.
19003        let adf_json = r#"{"version":1,"type":"doc","content":[{
19004          "type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},
19005          "content":[{"type":"tableRow","content":[{
19006            "type":"tableCell","attrs":{"colspan":1,"rowspan":1},
19007            "content":[{"type":"paragraph","content":[
19008              {"type":"text","text":"2. Honouring existing commitments"}
19009            ]}]
19010          }]}]
19011        }]}"#;
19012        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19013        let md = adf_to_markdown(&doc).unwrap();
19014        let rt = markdown_to_adf(&md).unwrap();
19015
19016        let table = &rt.content[0];
19017        let cell = &table.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0];
19018        let para = &cell.content.as_ref().unwrap()[0];
19019        assert_eq!(para.node_type, "paragraph");
19020        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
19021        assert_eq!(text, "2. Honouring existing commitments");
19022    }
19023
19024    #[test]
19025    fn bullet_marker_paragraph_standalone_roundtrips() {
19026        // A top-level paragraph starting with "- " must round-trip as
19027        // a paragraph, not a bullet list.
19028        let adf_json = r#"{"version":1,"type":"doc","content":[
19029          {"type":"paragraph","content":[
19030            {"type":"text","text":"- not a list item"}
19031          ]}
19032        ]}"#;
19033        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19034        let md = adf_to_markdown(&doc).unwrap();
19035        assert!(
19036            md.contains(r"\- not a list item"),
19037            "Should escape the leading dash, got:\n{md}"
19038        );
19039        let rt = markdown_to_adf(&md).unwrap();
19040        assert_eq!(rt.content[0].node_type, "paragraph");
19041        let text = rt.content[0].content.as_ref().unwrap()[0]
19042            .text
19043            .as_deref()
19044            .unwrap();
19045        assert_eq!(text, "- not a list item");
19046    }
19047
19048    #[test]
19049    fn merge_adjacent_text_skips_non_text_nodes() {
19050        // Exercises the `else { i += 1 }` branch when adjacent nodes
19051        // are not both plain text.
19052        let mut nodes = vec![
19053            AdfNode::text("a"),
19054            AdfNode::hard_break(),
19055            AdfNode::text("b"),
19056        ];
19057        merge_adjacent_text(&mut nodes);
19058        assert_eq!(nodes.len(), 3);
19059    }
19060
19061    #[test]
19062    fn star_bullet_paragraph_roundtrips() {
19063        // Paragraph starting with "* " must round-trip without becoming
19064        // a bullet list.
19065        let adf_json = r#"{"version":1,"type":"doc","content":[
19066          {"type":"paragraph","content":[
19067            {"type":"text","text":"* starred"}
19068          ]}
19069        ]}"#;
19070        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19071        let md = adf_to_markdown(&doc).unwrap();
19072        let rt = markdown_to_adf(&md).unwrap();
19073        assert_eq!(rt.content[0].node_type, "paragraph");
19074        assert_eq!(
19075            rt.content[0].content.as_ref().unwrap()[0]
19076                .text
19077                .as_deref()
19078                .unwrap(),
19079            "* starred"
19080        );
19081    }
19082
19083    // ---- Issue #388 tests ----
19084
19085    #[test]
19086    fn issue_388_ordered_list_with_strong_hardbreak_roundtrips() {
19087        // Issue #388: orderedList with 2 listItems, each containing
19088        // strong-marked text + hardBreak + plain text.
19089        let adf_json = r#"{"version":1,"type":"doc","content":[
19090          {"type":"orderedList","attrs":{"order":1},"content":[
19091            {"type":"listItem","content":[
19092              {"type":"paragraph","content":[
19093                {"type":"text","text":"Bold heading","marks":[{"type":"strong"}]},
19094                {"type":"hardBreak"},
19095                {"type":"text","text":"Content after break"}
19096              ]}
19097            ]},
19098            {"type":"listItem","content":[
19099              {"type":"paragraph","content":[
19100                {"type":"text","text":"Second item","marks":[{"type":"strong"}]},
19101                {"type":"hardBreak"},
19102                {"type":"text","text":"More content"}
19103              ]}
19104            ]}
19105          ]}
19106        ]}"#;
19107        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19108        let md = adf_to_markdown(&doc).unwrap();
19109        let rt = markdown_to_adf(&md).unwrap();
19110
19111        // Must remain a single orderedList
19112        assert_eq!(
19113            rt.content.len(),
19114            1,
19115            "Should be 1 block (orderedList), got {}",
19116            rt.content.len()
19117        );
19118        assert_eq!(rt.content[0].node_type, "orderedList");
19119        let items = rt.content[0].content.as_ref().unwrap();
19120        assert_eq!(
19121            items.len(),
19122            2,
19123            "Should have 2 listItems, got {}",
19124            items.len()
19125        );
19126
19127        // First item: text(strong) + hardBreak + text
19128        let p1 = items[0].content.as_ref().unwrap()[0]
19129            .content
19130            .as_ref()
19131            .unwrap();
19132        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
19133        assert_eq!(types1, vec!["text", "hardBreak", "text"]);
19134        assert_eq!(p1[0].text.as_deref(), Some("Bold heading"));
19135        assert_eq!(p1[2].text.as_deref(), Some("Content after break"));
19136
19137        // Second item: text(strong) + hardBreak + text
19138        let p2 = items[1].content.as_ref().unwrap()[0]
19139            .content
19140            .as_ref()
19141            .unwrap();
19142        let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
19143        assert_eq!(types2, vec!["text", "hardBreak", "text"]);
19144        assert_eq!(p2[0].text.as_deref(), Some("Second item"));
19145        assert_eq!(p2[2].text.as_deref(), Some("More content"));
19146    }
19147
19148    #[test]
19149    fn issue_388_bullet_list_with_strong_hardbreak_roundtrips() {
19150        // Bullet list variant of issue #388.
19151        let adf_json = r#"{"version":1,"type":"doc","content":[
19152          {"type":"bulletList","content":[
19153            {"type":"listItem","content":[
19154              {"type":"paragraph","content":[
19155                {"type":"text","text":"First","marks":[{"type":"strong"}]},
19156                {"type":"hardBreak"},
19157                {"type":"text","text":"details"}
19158              ]}
19159            ]},
19160            {"type":"listItem","content":[
19161              {"type":"paragraph","content":[
19162                {"type":"text","text":"Second","marks":[{"type":"em"}]},
19163                {"type":"hardBreak"},
19164                {"type":"text","text":"more details"}
19165              ]}
19166            ]}
19167          ]}
19168        ]}"#;
19169        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19170        let md = adf_to_markdown(&doc).unwrap();
19171        let rt = markdown_to_adf(&md).unwrap();
19172
19173        assert_eq!(rt.content.len(), 1);
19174        assert_eq!(rt.content[0].node_type, "bulletList");
19175        let items = rt.content[0].content.as_ref().unwrap();
19176        assert_eq!(items.len(), 2);
19177
19178        let p1 = items[0].content.as_ref().unwrap()[0]
19179            .content
19180            .as_ref()
19181            .unwrap();
19182        assert_eq!(p1[0].text.as_deref(), Some("First"));
19183        assert_eq!(p1[2].text.as_deref(), Some("details"));
19184
19185        let p2 = items[1].content.as_ref().unwrap()[0]
19186            .content
19187            .as_ref()
19188            .unwrap();
19189        assert_eq!(p2[0].text.as_deref(), Some("Second"));
19190        assert_eq!(p2[2].text.as_deref(), Some("more details"));
19191    }
19192
19193    #[test]
19194    fn issue_388_ordered_list_hardbreak_jfm_indentation() {
19195        // Verify the JFM output has properly indented continuation lines.
19196        let adf_json = r#"{"version":1,"type":"doc","content":[
19197          {"type":"orderedList","attrs":{"order":1},"content":[
19198            {"type":"listItem","content":[
19199              {"type":"paragraph","content":[
19200                {"type":"text","text":"heading","marks":[{"type":"strong"}]},
19201                {"type":"hardBreak"},
19202                {"type":"text","text":"body"}
19203              ]}
19204            ]}
19205          ]}
19206        ]}"#;
19207        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19208        let md = adf_to_markdown(&doc).unwrap();
19209        assert!(
19210            md.contains("1. **heading**\\\n  body"),
19211            "Continuation should be indented, got:\n{md}"
19212        );
19213    }
19214
19215    #[test]
19216    fn issue_388_ordered_list_hardbreak_from_jfm() {
19217        // Direct JFM → ADF: ordered list with hardBreak continuation.
19218        let md = "1. **bold**\\\n  continued\n2. **also bold**\\\n  also continued\n";
19219        let doc = markdown_to_adf(md).unwrap();
19220
19221        assert_eq!(doc.content.len(), 1);
19222        assert_eq!(doc.content[0].node_type, "orderedList");
19223        let items = doc.content[0].content.as_ref().unwrap();
19224        assert_eq!(items.len(), 2);
19225
19226        let p1 = items[0].content.as_ref().unwrap()[0]
19227            .content
19228            .as_ref()
19229            .unwrap();
19230        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
19231        assert_eq!(types1, vec!["text", "hardBreak", "text"]);
19232        assert_eq!(p1[0].text.as_deref(), Some("bold"));
19233        assert_eq!(p1[2].text.as_deref(), Some("continued"));
19234
19235        let p2 = items[1].content.as_ref().unwrap()[0]
19236            .content
19237            .as_ref()
19238            .unwrap();
19239        let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
19240        assert_eq!(types2, vec!["text", "hardBreak", "text"]);
19241    }
19242
19243    #[test]
19244    fn issue_388_bullet_list_hardbreak_from_jfm() {
19245        // Direct JFM → ADF: bullet list with hardBreak continuation.
19246        let md = "- first\\\n  second\n- third\\\n  fourth\n";
19247        let doc = markdown_to_adf(md).unwrap();
19248
19249        assert_eq!(doc.content.len(), 1);
19250        assert_eq!(doc.content[0].node_type, "bulletList");
19251        let items = doc.content[0].content.as_ref().unwrap();
19252        assert_eq!(items.len(), 2);
19253
19254        for (i, expected) in [("first", "second"), ("third", "fourth")]
19255            .iter()
19256            .enumerate()
19257        {
19258            let p = items[i].content.as_ref().unwrap()[0]
19259                .content
19260                .as_ref()
19261                .unwrap();
19262            let types: Vec<&str> = p.iter().map(|n| n.node_type.as_str()).collect();
19263            assert_eq!(types, vec!["text", "hardBreak", "text"]);
19264            assert_eq!(p[0].text.as_deref(), Some(expected.0));
19265            assert_eq!(p[2].text.as_deref(), Some(expected.1));
19266        }
19267    }
19268
19269    #[test]
19270    fn issue_433_heading_hardbreak_roundtrips() {
19271        // Issue #433: hardBreak inside heading splits into heading + paragraph.
19272        let adf_json = r#"{"version":1,"type":"doc","content":[{
19273          "type":"heading",
19274          "attrs":{"level":1},
19275          "content":[
19276            {"type":"text","text":"Line one"},
19277            {"type":"hardBreak"},
19278            {"type":"text","text":"Line two"}
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!(
19286            rt.content.len(),
19287            1,
19288            "Should remain a single heading, got {} blocks",
19289            rt.content.len()
19290        );
19291        assert_eq!(rt.content[0].node_type, "heading");
19292        let inlines = rt.content[0].content.as_ref().unwrap();
19293        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19294        assert_eq!(
19295            types,
19296            vec!["text", "hardBreak", "text"],
19297            "hardBreak should be preserved, got: {types:?}"
19298        );
19299        assert_eq!(inlines[0].text.as_deref(), Some("Line one"));
19300        assert_eq!(inlines[2].text.as_deref(), Some("Line two"));
19301    }
19302
19303    #[test]
19304    fn issue_433_heading_hardbreak_jfm_indentation() {
19305        // Verify the JFM output has properly indented continuation lines.
19306        let adf_json = r#"{"version":1,"type":"doc","content":[{
19307          "type":"heading",
19308          "attrs":{"level":2},
19309          "content":[
19310            {"type":"text","text":"Title"},
19311            {"type":"hardBreak"},
19312            {"type":"text","text":"Subtitle"}
19313          ]
19314        }]}"#;
19315        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19316        let md = adf_to_markdown(&doc).unwrap();
19317        assert!(
19318            md.contains("## Title\\\n  Subtitle"),
19319            "Continuation should be indented, got:\n{md}"
19320        );
19321    }
19322
19323    #[test]
19324    fn issue_433_heading_hardbreak_from_jfm() {
19325        // Direct JFM → ADF: heading with hardBreak continuation.
19326        let md = "# First\\\n  Second\n";
19327        let doc = markdown_to_adf(md).unwrap();
19328
19329        assert_eq!(doc.content.len(), 1);
19330        assert_eq!(doc.content[0].node_type, "heading");
19331        let inlines = doc.content[0].content.as_ref().unwrap();
19332        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19333        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19334        assert_eq!(inlines[0].text.as_deref(), Some("First"));
19335        assert_eq!(inlines[2].text.as_deref(), Some("Second"));
19336    }
19337
19338    #[test]
19339    fn issue_433_heading_consecutive_hardbreaks_roundtrip() {
19340        // Consecutive hardBreaks in a heading.
19341        let adf_json = r#"{"version":1,"type":"doc","content":[{
19342          "type":"heading",
19343          "attrs":{"level":3},
19344          "content":[
19345            {"type":"text","text":"A"},
19346            {"type":"hardBreak"},
19347            {"type":"hardBreak"},
19348            {"type":"text","text":"B"}
19349          ]
19350        }]}"#;
19351        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19352        let md = adf_to_markdown(&doc).unwrap();
19353        let rt = markdown_to_adf(&md).unwrap();
19354
19355        assert_eq!(rt.content.len(), 1, "Should remain a single heading");
19356        assert_eq!(rt.content[0].node_type, "heading");
19357        let inlines = rt.content[0].content.as_ref().unwrap();
19358        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19359        assert_eq!(types, vec!["text", "hardBreak", "hardBreak", "text"]);
19360    }
19361
19362    #[test]
19363    fn issue_433_heading_with_strong_and_hardbreak_roundtrips() {
19364        // Heading with strong-marked text + hardBreak + plain text.
19365        let adf_json = r#"{"version":1,"type":"doc","content":[{
19366          "type":"heading",
19367          "attrs":{"level":1},
19368          "content":[
19369            {"type":"text","text":"Bold title","marks":[{"type":"strong"}]},
19370            {"type":"hardBreak"},
19371            {"type":"text","text":"plain continuation"}
19372          ]
19373        }]}"#;
19374        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19375        let md = adf_to_markdown(&doc).unwrap();
19376        let rt = markdown_to_adf(&md).unwrap();
19377
19378        assert_eq!(rt.content.len(), 1);
19379        assert_eq!(rt.content[0].node_type, "heading");
19380        let inlines = rt.content[0].content.as_ref().unwrap();
19381        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19382        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19383        assert_eq!(inlines[0].text.as_deref(), Some("Bold title"));
19384        assert_eq!(inlines[2].text.as_deref(), Some("plain continuation"));
19385    }
19386
19387    #[test]
19388    fn issue_433_heading_with_link_and_hardbreak_roundtrips() {
19389        // Real-world pattern: heading with link + hardBreak + text.
19390        let adf_json = r#"{"version":1,"type":"doc","content":[{
19391          "type":"heading",
19392          "attrs":{"level":1},
19393          "content":[
19394            {"type":"text","text":"Click here","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
19395            {"type":"hardBreak"},
19396            {"type":"text","text":"Subtitle text"}
19397          ]
19398        }]}"#;
19399        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19400        let md = adf_to_markdown(&doc).unwrap();
19401        let rt = markdown_to_adf(&md).unwrap();
19402
19403        assert_eq!(rt.content.len(), 1);
19404        assert_eq!(rt.content[0].node_type, "heading");
19405        let inlines = rt.content[0].content.as_ref().unwrap();
19406        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19407        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19408        assert_eq!(inlines[2].text.as_deref(), Some("Subtitle text"));
19409    }
19410
19411    #[test]
19412    fn has_trailing_hard_break_backslash() {
19413        assert!(has_trailing_hard_break("text\\"));
19414        assert!(has_trailing_hard_break("**bold**\\"));
19415    }
19416
19417    #[test]
19418    fn has_trailing_hard_break_trailing_spaces() {
19419        assert!(has_trailing_hard_break("text  "));
19420        assert!(has_trailing_hard_break("word   "));
19421    }
19422
19423    #[test]
19424    fn has_trailing_hard_break_false() {
19425        assert!(!has_trailing_hard_break("plain text"));
19426        assert!(!has_trailing_hard_break("text "));
19427        assert!(!has_trailing_hard_break(""));
19428    }
19429
19430    #[test]
19431    fn collect_hardbreak_continuations_collects_indented() {
19432        // A line ending with `\` followed by 2-space-indented continuation.
19433        // Only one line is collected because the result no longer ends with `\`.
19434        let input = "first\\\n  second\n  third\n";
19435        let mut parser = MarkdownParser::new(input);
19436        parser.advance(); // skip first line
19437        let mut text = "first\\".to_string();
19438        parser.collect_hardbreak_continuations(&mut text);
19439        assert_eq!(text, "first\\\nsecond");
19440    }
19441
19442    #[test]
19443    fn collect_hardbreak_continuations_stops_at_non_indented() {
19444        let input = "first\\\nnot indented\n";
19445        let mut parser = MarkdownParser::new(input);
19446        parser.advance();
19447        let mut text = "first\\".to_string();
19448        parser.collect_hardbreak_continuations(&mut text);
19449        // Should NOT collect the non-indented line
19450        assert_eq!(text, "first\\");
19451    }
19452
19453    #[test]
19454    fn collect_hardbreak_continuations_no_trailing_break() {
19455        // If the text doesn't end with a hardBreak marker, nothing is collected.
19456        let input = "plain\n  indented\n";
19457        let mut parser = MarkdownParser::new(input);
19458        parser.advance();
19459        let mut text = "plain".to_string();
19460        parser.collect_hardbreak_continuations(&mut text);
19461        assert_eq!(text, "plain");
19462    }
19463
19464    #[test]
19465    fn collect_hardbreak_continuations_chained() {
19466        // Multiple continuation lines chained via repeated hardBreaks.
19467        let input = "a\\\n  b\\\n  c\\\n  d\n";
19468        let mut parser = MarkdownParser::new(input);
19469        parser.advance();
19470        let mut text = "a\\".to_string();
19471        parser.collect_hardbreak_continuations(&mut text);
19472        assert_eq!(text, "a\\\nb\\\nc\\\nd");
19473    }
19474
19475    #[test]
19476    fn collect_hardbreak_continuations_stops_before_image_line() {
19477        // An indented continuation that starts with `![` (mediaSingle syntax)
19478        // must NOT be swallowed as a paragraph continuation (issue #490).
19479        let input = "text\\\n  ![](url){type=file id=x}\n";
19480        let mut parser = MarkdownParser::new(input);
19481        parser.advance(); // skip first line
19482        let mut text = "text\\".to_string();
19483        parser.collect_hardbreak_continuations(&mut text);
19484        // The image line should NOT have been consumed.
19485        assert_eq!(text, "text\\");
19486        // Parser should still be on the image line (not past it).
19487        assert!(!parser.at_end());
19488        assert!(parser.current_line().contains("![](url)"));
19489    }
19490
19491    #[test]
19492    fn is_block_level_continuation_marker_positive_cases() {
19493        // Each marker that forces `collect_hardbreak_continuations` to stop.
19494        assert!(is_block_level_continuation_marker("![](url)"));
19495        assert!(is_block_level_continuation_marker("```ruby"));
19496        assert!(is_block_level_continuation_marker(":::panel{type=info}"));
19497    }
19498
19499    #[test]
19500    fn is_block_level_continuation_marker_negative_cases() {
19501        // Plain continuation text must NOT look like a block-level marker.
19502        assert!(!is_block_level_continuation_marker("plain text"));
19503        assert!(!is_block_level_continuation_marker("- nested item"));
19504        assert!(!is_block_level_continuation_marker("continuation\\"));
19505        assert!(!is_block_level_continuation_marker(""));
19506        // Double-colon `::` is not a container directive.
19507        assert!(!is_block_level_continuation_marker("::partial"));
19508        // Single backticks are inline code, not a fence.
19509        assert!(!is_block_level_continuation_marker("`inline`"));
19510    }
19511
19512    #[test]
19513    fn collect_hardbreak_continuations_stops_before_code_fence() {
19514        // Issue #552: An indented continuation that opens a fenced code block
19515        // must NOT be swallowed as a paragraph continuation — it has to stay
19516        // available for `try_code_block` on the next parse iteration.
19517        let input = "text\\\n  ```ruby\n  Foo::Bar::Baz\n  ```\n";
19518        let mut parser = MarkdownParser::new(input);
19519        parser.advance();
19520        let mut text = "text\\".to_string();
19521        parser.collect_hardbreak_continuations(&mut text);
19522        assert_eq!(text, "text\\");
19523        assert!(!parser.at_end());
19524        assert!(parser.current_line().starts_with("  ```"));
19525    }
19526
19527    #[test]
19528    fn collect_hardbreak_continuations_stops_before_container_directive() {
19529        // Issue #552: An indented continuation that opens a `:::` container
19530        // directive (panel, expand, etc.) must also stay available for the
19531        // directive parser.
19532        let input = "text\\\n  :::panel{type=info}\n  body\n  :::\n";
19533        let mut parser = MarkdownParser::new(input);
19534        parser.advance();
19535        let mut text = "text\\".to_string();
19536        parser.collect_hardbreak_continuations(&mut text);
19537        assert_eq!(text, "text\\");
19538        assert!(!parser.at_end());
19539        assert!(parser.current_line().contains(":::panel"));
19540    }
19541
19542    #[test]
19543    fn collect_hardbreak_continuations_stops_before_indented_code_fence() {
19544        // Variant: extra leading whitespace on the code-fence line (so the
19545        // stripped tail is `  ```` rather than a bare ` ``` `) must still be
19546        // recognised by the `trim_start().starts_with("```")` check.
19547        let input = "text\\\n     ```text\n     :fire:\n     ```\n";
19548        let mut parser = MarkdownParser::new(input);
19549        parser.advance();
19550        let mut text = "text\\".to_string();
19551        parser.collect_hardbreak_continuations(&mut text);
19552        assert_eq!(text, "text\\");
19553        assert!(!parser.at_end());
19554        assert!(parser.current_line().contains("```text"));
19555    }
19556
19557    #[test]
19558    fn ordered_list_with_sub_content_after_hardbreak() {
19559        // Exercises the sub-content collection loop in parse_ordered_list
19560        // (lines 339-347) with a hardBreak item that also has a nested list.
19561        let adf_json = r#"{"version":1,"type":"doc","content":[
19562          {"type":"orderedList","attrs":{"order":1},"content":[
19563            {"type":"listItem","content":[
19564              {"type":"paragraph","content":[
19565                {"type":"text","text":"parent"},
19566                {"type":"hardBreak"},
19567                {"type":"text","text":"continued"}
19568              ]},
19569              {"type":"bulletList","content":[
19570                {"type":"listItem","content":[
19571                  {"type":"paragraph","content":[
19572                    {"type":"text","text":"child"}
19573                  ]}
19574                ]}
19575              ]}
19576            ]}
19577          ]}
19578        ]}"#;
19579        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19580        let md = adf_to_markdown(&doc).unwrap();
19581        let rt = markdown_to_adf(&md).unwrap();
19582
19583        assert_eq!(rt.content.len(), 1);
19584        assert_eq!(rt.content[0].node_type, "orderedList");
19585        let item_content = rt.content[0].content.as_ref().unwrap()[0]
19586            .content
19587            .as_ref()
19588            .unwrap();
19589        // Paragraph with hardBreak
19590        let p = item_content[0].content.as_ref().unwrap();
19591        let types: Vec<&str> = p.iter().map(|n| n.node_type.as_str()).collect();
19592        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19593        assert_eq!(p[0].text.as_deref(), Some("parent"));
19594        assert_eq!(p[2].text.as_deref(), Some("continued"));
19595        // Nested bullet list
19596        assert_eq!(item_content[1].node_type, "bulletList");
19597    }
19598
19599    #[test]
19600    fn render_list_item_content_no_content() {
19601        // A listItem with content: None should produce just a newline.
19602        let item = AdfNode {
19603            node_type: "listItem".to_string(),
19604            attrs: None,
19605            content: None,
19606            text: None,
19607            marks: None,
19608            local_id: None,
19609            parameters: None,
19610        };
19611        let mut output = String::new();
19612        let opts = RenderOptions::default();
19613        render_list_item_content(&item, &mut output, &opts);
19614        assert_eq!(output, "\n");
19615    }
19616
19617    #[test]
19618    fn render_list_item_content_empty_content() {
19619        // A listItem with content: Some(vec![]) should produce just a newline.
19620        let item = AdfNode::list_item(vec![]);
19621        let mut output = String::new();
19622        let opts = RenderOptions::default();
19623        render_list_item_content(&item, &mut output, &opts);
19624        assert_eq!(output, "\n");
19625    }
19626
19627    #[test]
19628    fn plus_bullet_paragraph_roundtrips() {
19629        // Paragraph starting with "+ " must round-trip without becoming
19630        // a bullet list.
19631        let adf_json = r#"{"version":1,"type":"doc","content":[
19632          {"type":"paragraph","content":[
19633            {"type":"text","text":"+ plus"}
19634          ]}
19635        ]}"#;
19636        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19637        let md = adf_to_markdown(&doc).unwrap();
19638        let rt = markdown_to_adf(&md).unwrap();
19639        assert_eq!(rt.content[0].node_type, "paragraph");
19640        assert_eq!(
19641            rt.content[0].content.as_ref().unwrap()[0]
19642                .text
19643                .as_deref()
19644                .unwrap(),
19645            "+ plus"
19646        );
19647    }
19648
19649    // ---- Issue #430 tests: mediaSingle inside listItem ----
19650
19651    #[test]
19652    fn issue_430_file_media_in_bullet_list_roundtrip() {
19653        // Issue #430: mediaSingle (type:file) as direct child of listItem
19654        // in a bulletList must survive round-trip.
19655        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19656          {"type":"listItem","content":[{
19657            "type":"mediaSingle",
19658            "attrs":{"layout":"center","width":1009,"widthType":"pixel"},
19659            "content":[{
19660              "type":"media",
19661              "attrs":{"collection":"contentId-123","height":576,"id":"00066e8e-554e-4d7e-af59-a0ef2888bdb6","type":"file","width":1009}
19662            }]
19663          }]}
19664        ]}]}"#;
19665        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19666        let md = adf_to_markdown(&doc).unwrap();
19667        let rt = markdown_to_adf(&md).unwrap();
19668
19669        let list = &rt.content[0];
19670        assert_eq!(list.node_type, "bulletList");
19671        let item = &list.content.as_ref().unwrap()[0];
19672        assert_eq!(item.node_type, "listItem");
19673        let ms = &item.content.as_ref().unwrap()[0];
19674        assert_eq!(ms.node_type, "mediaSingle");
19675        let ms_attrs = ms.attrs.as_ref().unwrap();
19676        assert_eq!(ms_attrs["layout"], "center");
19677        assert_eq!(ms_attrs["width"], 1009);
19678        assert_eq!(ms_attrs["widthType"], "pixel");
19679        let media = &ms.content.as_ref().unwrap()[0];
19680        assert_eq!(media.node_type, "media");
19681        let m_attrs = media.attrs.as_ref().unwrap();
19682        assert_eq!(m_attrs["type"], "file");
19683        assert_eq!(m_attrs["id"], "00066e8e-554e-4d7e-af59-a0ef2888bdb6");
19684        assert_eq!(m_attrs["collection"], "contentId-123");
19685        assert_eq!(m_attrs["height"], 576);
19686        assert_eq!(m_attrs["width"], 1009);
19687    }
19688
19689    #[test]
19690    fn issue_430_file_media_in_ordered_list_roundtrip() {
19691        // Same as above but inside an orderedList.
19692        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
19693          {"type":"listItem","content":[{
19694            "type":"mediaSingle",
19695            "attrs":{"layout":"center"},
19696            "content":[{
19697              "type":"media",
19698              "attrs":{"type":"file","id":"abc-123","collection":"contentId-456","height":100,"width":200}
19699            }]
19700          }]}
19701        ]}]}"#;
19702        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19703        let md = adf_to_markdown(&doc).unwrap();
19704        let rt = markdown_to_adf(&md).unwrap();
19705
19706        let list = &rt.content[0];
19707        assert_eq!(list.node_type, "orderedList");
19708        let item = &list.content.as_ref().unwrap()[0];
19709        assert_eq!(item.node_type, "listItem");
19710        let ms = &item.content.as_ref().unwrap()[0];
19711        assert_eq!(ms.node_type, "mediaSingle");
19712        let media = &ms.content.as_ref().unwrap()[0];
19713        assert_eq!(media.node_type, "media");
19714        let m_attrs = media.attrs.as_ref().unwrap();
19715        assert_eq!(m_attrs["type"], "file");
19716        assert_eq!(m_attrs["id"], "abc-123");
19717        assert_eq!(m_attrs["collection"], "contentId-456");
19718    }
19719
19720    #[test]
19721    fn issue_430_external_media_in_bullet_list_roundtrip() {
19722        // External image (type:external) inside a bullet list item.
19723        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19724          {"type":"listItem","content":[{
19725            "type":"mediaSingle",
19726            "attrs":{"layout":"center"},
19727            "content":[{
19728              "type":"media",
19729              "attrs":{"type":"external","url":"https://example.com/img.png","alt":"Photo"}
19730            }]
19731          }]}
19732        ]}]}"#;
19733        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19734        let md = adf_to_markdown(&doc).unwrap();
19735        let rt = markdown_to_adf(&md).unwrap();
19736
19737        let list = &rt.content[0];
19738        assert_eq!(list.node_type, "bulletList");
19739        let item = &list.content.as_ref().unwrap()[0];
19740        let ms = &item.content.as_ref().unwrap()[0];
19741        assert_eq!(ms.node_type, "mediaSingle");
19742        let media = &ms.content.as_ref().unwrap()[0];
19743        assert_eq!(media.node_type, "media");
19744        let m_attrs = media.attrs.as_ref().unwrap();
19745        assert_eq!(m_attrs["type"], "external");
19746        assert_eq!(m_attrs["url"], "https://example.com/img.png");
19747    }
19748
19749    #[test]
19750    fn issue_430_media_with_paragraph_siblings_in_list_item() {
19751        // listItem containing a paragraph followed by a mediaSingle.
19752        // Both children must survive round-trip.
19753        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19754          {"type":"listItem","content":[
19755            {"type":"paragraph","content":[{"type":"text","text":"Caption:"}]},
19756            {"type":"mediaSingle","attrs":{"layout":"center"},
19757             "content":[{"type":"media","attrs":{"type":"file","id":"img-001","collection":"col-1","height":50,"width":100}}]}
19758          ]}
19759        ]}]}"#;
19760        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19761        let md = adf_to_markdown(&doc).unwrap();
19762        let rt = markdown_to_adf(&md).unwrap();
19763
19764        let item = &rt.content[0].content.as_ref().unwrap()[0];
19765        let children = item.content.as_ref().unwrap();
19766        assert_eq!(children.len(), 2, "expected 2 children in listItem");
19767        assert_eq!(children[0].node_type, "paragraph");
19768        assert_eq!(children[1].node_type, "mediaSingle");
19769        let media = &children[1].content.as_ref().unwrap()[0];
19770        assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-001");
19771    }
19772
19773    #[test]
19774    fn issue_430_multiple_media_in_list_items() {
19775        // Multiple list items each containing mediaSingle.
19776        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19777          {"type":"listItem","content":[{
19778            "type":"mediaSingle","attrs":{"layout":"center"},
19779            "content":[{"type":"media","attrs":{"type":"file","id":"img-a","collection":"c1","height":10,"width":20}}]
19780          }]},
19781          {"type":"listItem","content":[{
19782            "type":"mediaSingle","attrs":{"layout":"center"},
19783            "content":[{"type":"media","attrs":{"type":"file","id":"img-b","collection":"c2","height":30,"width":40}}]
19784          }]}
19785        ]}]}"#;
19786        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19787        let md = adf_to_markdown(&doc).unwrap();
19788        let rt = markdown_to_adf(&md).unwrap();
19789
19790        let items = rt.content[0].content.as_ref().unwrap();
19791        assert_eq!(items.len(), 2);
19792        for (i, expected_id) in [("img-a", "c1"), ("img-b", "c2")].iter().enumerate() {
19793            let ms = &items[i].content.as_ref().unwrap()[0];
19794            assert_eq!(ms.node_type, "mediaSingle");
19795            let m_attrs = ms.content.as_ref().unwrap()[0].attrs.as_ref().unwrap();
19796            assert_eq!(m_attrs["id"], expected_id.0);
19797            assert_eq!(m_attrs["collection"], expected_id.1);
19798        }
19799    }
19800
19801    #[test]
19802    fn issue_430_jfm_to_adf_media_in_bullet_item() {
19803        // Parse JFM directly: image syntax on the first line of a bullet item
19804        // must produce mediaSingle, not a paragraph with corrupted text.
19805        let md = "- ![](){type=file id=test-id collection=col-1 height=100 width=200}\n";
19806        let doc = markdown_to_adf(md).unwrap();
19807
19808        let list = &doc.content[0];
19809        assert_eq!(list.node_type, "bulletList");
19810        let item = &list.content.as_ref().unwrap()[0];
19811        let ms = &item.content.as_ref().unwrap()[0];
19812        assert_eq!(
19813            ms.node_type, "mediaSingle",
19814            "expected mediaSingle, got {}",
19815            ms.node_type
19816        );
19817        let media = &ms.content.as_ref().unwrap()[0];
19818        assert_eq!(media.node_type, "media");
19819        let m_attrs = media.attrs.as_ref().unwrap();
19820        assert_eq!(m_attrs["type"], "file");
19821        assert_eq!(m_attrs["id"], "test-id");
19822    }
19823
19824    #[test]
19825    fn issue_430_jfm_to_adf_media_in_ordered_item() {
19826        // Parse JFM directly: image syntax on the first line of an ordered list item.
19827        let md = "1. ![alt text](https://example.com/photo.jpg)\n";
19828        let doc = markdown_to_adf(md).unwrap();
19829
19830        let list = &doc.content[0];
19831        assert_eq!(list.node_type, "orderedList");
19832        let item = &list.content.as_ref().unwrap()[0];
19833        let ms = &item.content.as_ref().unwrap()[0];
19834        assert_eq!(
19835            ms.node_type, "mediaSingle",
19836            "expected mediaSingle, got {}",
19837            ms.node_type
19838        );
19839    }
19840
19841    #[test]
19842    fn issue_430_media_then_paragraph_in_bullet_list_roundtrip() {
19843        // listItem with mediaSingle as first child followed by a paragraph.
19844        // Exercises the sub_lines non-empty path when first_node is mediaSingle.
19845        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19846          {"type":"listItem","content":[
19847            {"type":"mediaSingle","attrs":{"layout":"center"},
19848             "content":[{"type":"media","attrs":{"type":"file","id":"img-first","collection":"col-1","height":50,"width":100}}]},
19849            {"type":"paragraph","content":[{"type":"text","text":"Caption below"}]}
19850          ]}
19851        ]}]}"#;
19852        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19853        let md = adf_to_markdown(&doc).unwrap();
19854        let rt = markdown_to_adf(&md).unwrap();
19855
19856        let item = &rt.content[0].content.as_ref().unwrap()[0];
19857        let children = item.content.as_ref().unwrap();
19858        assert_eq!(children.len(), 2, "expected 2 children in listItem");
19859        assert_eq!(children[0].node_type, "mediaSingle");
19860        let media = &children[0].content.as_ref().unwrap()[0];
19861        assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-first");
19862        assert_eq!(children[1].node_type, "paragraph");
19863    }
19864
19865    #[test]
19866    fn issue_430_media_then_paragraph_in_ordered_list_roundtrip() {
19867        // Same as above but for ordered lists.
19868        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
19869          {"type":"listItem","content":[
19870            {"type":"mediaSingle","attrs":{"layout":"center"},
19871             "content":[{"type":"media","attrs":{"type":"file","id":"img-ord","collection":"col-2","height":60,"width":120}}]},
19872            {"type":"paragraph","content":[{"type":"text","text":"Description"}]}
19873          ]}
19874        ]}]}"#;
19875        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19876        let md = adf_to_markdown(&doc).unwrap();
19877        let rt = markdown_to_adf(&md).unwrap();
19878
19879        let item = &rt.content[0].content.as_ref().unwrap()[0];
19880        let children = item.content.as_ref().unwrap();
19881        assert_eq!(children.len(), 2, "expected 2 children in listItem");
19882        assert_eq!(children[0].node_type, "mediaSingle");
19883        assert_eq!(children[1].node_type, "paragraph");
19884    }
19885
19886    #[test]
19887    fn issue_430_external_media_with_width_type_roundtrip() {
19888        // External image with widthType attr must survive round-trip.
19889        let adf_json = r#"{"version":1,"type":"doc","content":[{
19890          "type":"mediaSingle",
19891          "attrs":{"layout":"wide","width":800,"widthType":"pixel"},
19892          "content":[{
19893            "type":"media",
19894            "attrs":{"type":"external","url":"https://example.com/photo.png","alt":"wide photo"}
19895          }]
19896        }]}"#;
19897        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19898        let md = adf_to_markdown(&doc).unwrap();
19899        assert!(
19900            md.contains("widthType=pixel"),
19901            "expected widthType=pixel in markdown, got: {md}"
19902        );
19903        let rt = markdown_to_adf(&md).unwrap();
19904        let ms = &rt.content[0];
19905        assert_eq!(ms.node_type, "mediaSingle");
19906        let ms_attrs = ms.attrs.as_ref().unwrap();
19907        assert_eq!(ms_attrs["widthType"], "pixel");
19908        assert_eq!(ms_attrs["width"], 800);
19909        assert_eq!(ms_attrs["layout"], "wide");
19910    }
19911
19912    // ── Issue #490: mediaSingle after hardBreak in listItem ─────
19913
19914    #[test]
19915    fn issue_490_paragraph_with_hardbreak_then_media_single_roundtrip() {
19916        // Reproducer from issue #490: paragraph with trailing hardBreak
19917        // followed by mediaSingle inside a listItem.
19918        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19919          {"type":"listItem","content":[
19920            {"type":"paragraph","content":[
19921              {"type":"text","text":"Item with image:"},
19922              {"type":"hardBreak"}
19923            ]},
19924            {"type":"mediaSingle","attrs":{"layout":"center","width":400,"widthType":"pixel"},
19925             "content":[{"type":"media","attrs":{
19926               "id":"aabbccdd-1234-5678-abcd-aabbccdd1234",
19927               "type":"file",
19928               "collection":"contentId-123456",
19929               "width":800,
19930               "height":600
19931             }}]}
19932          ]}
19933        ]}]}"#;
19934        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19935        let md = adf_to_markdown(&doc).unwrap();
19936        let rt = markdown_to_adf(&md).unwrap();
19937
19938        let item = &rt.content[0].content.as_ref().unwrap()[0];
19939        let children = item.content.as_ref().unwrap();
19940        assert_eq!(children.len(), 2, "expected 2 children in listItem");
19941        assert_eq!(children[0].node_type, "paragraph");
19942        assert_eq!(
19943            children[1].node_type, "mediaSingle",
19944            "expected mediaSingle, got {:?}",
19945            children[1].node_type
19946        );
19947        let media = &children[1].content.as_ref().unwrap()[0];
19948        let m_attrs = media.attrs.as_ref().unwrap();
19949        assert_eq!(m_attrs["id"], "aabbccdd-1234-5678-abcd-aabbccdd1234");
19950        assert_eq!(m_attrs["collection"], "contentId-123456");
19951        assert_eq!(m_attrs["height"], 600);
19952        assert_eq!(m_attrs["width"], 800);
19953    }
19954
19955    #[test]
19956    fn issue_490_paragraph_with_hardbreak_then_media_single_ordered_list() {
19957        // Same scenario but in an ordered list.
19958        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
19959          {"type":"listItem","content":[
19960            {"type":"paragraph","content":[
19961              {"type":"text","text":"Step with screenshot:"},
19962              {"type":"hardBreak"}
19963            ]},
19964            {"type":"mediaSingle","attrs":{"layout":"center"},
19965             "content":[{"type":"media","attrs":{
19966               "id":"ord-media-id","type":"file","collection":"col-ord","width":640,"height":480
19967             }}]}
19968          ]}
19969        ]}]}"#;
19970        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19971        let md = adf_to_markdown(&doc).unwrap();
19972        let rt = markdown_to_adf(&md).unwrap();
19973
19974        let item = &rt.content[0].content.as_ref().unwrap()[0];
19975        let children = item.content.as_ref().unwrap();
19976        assert_eq!(children.len(), 2, "expected 2 children in listItem");
19977        assert_eq!(children[0].node_type, "paragraph");
19978        assert_eq!(children[1].node_type, "mediaSingle");
19979        let media = &children[1].content.as_ref().unwrap()[0];
19980        assert_eq!(media.attrs.as_ref().unwrap()["id"], "ord-media-id");
19981    }
19982
19983    #[test]
19984    fn issue_490_hardbreak_continuation_does_not_swallow_media_line() {
19985        // Directly tests that collect_hardbreak_continuations stops before
19986        // an indented mediaSingle line.
19987        let md = "- Item with image:\\\n  ![](){type=file id=test-490 collection=col height=100 width=200}\n";
19988        let doc = markdown_to_adf(md).unwrap();
19989
19990        let item = &doc.content[0].content.as_ref().unwrap()[0];
19991        let children = item.content.as_ref().unwrap();
19992        assert_eq!(children.len(), 2, "expected 2 children in listItem");
19993        assert_eq!(children[0].node_type, "paragraph");
19994        assert_eq!(
19995            children[1].node_type, "mediaSingle",
19996            "expected mediaSingle as second child, got {:?}",
19997            children[1].node_type
19998        );
19999        let media = &children[1].content.as_ref().unwrap()[0];
20000        assert_eq!(media.attrs.as_ref().unwrap()["id"], "test-490");
20001    }
20002
20003    #[test]
20004    fn issue_490_hardbreak_continuation_still_works_for_text() {
20005        // Ensure regular hardBreak continuations still work after the fix.
20006        let md = "- first line\\\n  second line\n";
20007        let doc = markdown_to_adf(md).unwrap();
20008
20009        let item = &doc.content[0].content.as_ref().unwrap()[0];
20010        let children = item.content.as_ref().unwrap();
20011        assert_eq!(
20012            children.len(),
20013            1,
20014            "expected 1 child (paragraph) in listItem"
20015        );
20016        assert_eq!(children[0].node_type, "paragraph");
20017        let inlines = children[0].content.as_ref().unwrap();
20018        // Should contain: text("first line"), hardBreak, text("second line")
20019        assert_eq!(inlines.len(), 3);
20020        assert_eq!(inlines[0].node_type, "text");
20021        assert_eq!(inlines[1].node_type, "hardBreak");
20022        assert_eq!(inlines[2].node_type, "text");
20023    }
20024
20025    #[test]
20026    fn issue_490_external_media_after_hardbreak_roundtrip() {
20027        // External image (URL-based) after a paragraph with hardBreak.
20028        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20029          {"type":"listItem","content":[
20030            {"type":"paragraph","content":[
20031              {"type":"text","text":"See image:"},
20032              {"type":"hardBreak"}
20033            ]},
20034            {"type":"mediaSingle","attrs":{"layout":"center"},
20035             "content":[{"type":"media","attrs":{
20036               "type":"external","url":"https://example.com/photo.png","alt":"photo"
20037             }}]}
20038          ]}
20039        ]}]}"#;
20040        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20041        let md = adf_to_markdown(&doc).unwrap();
20042        let rt = markdown_to_adf(&md).unwrap();
20043
20044        let item = &rt.content[0].content.as_ref().unwrap()[0];
20045        let children = item.content.as_ref().unwrap();
20046        assert_eq!(children.len(), 2);
20047        assert_eq!(children[0].node_type, "paragraph");
20048        assert_eq!(children[1].node_type, "mediaSingle");
20049        let media = &children[1].content.as_ref().unwrap()[0];
20050        let m_attrs = media.attrs.as_ref().unwrap();
20051        assert_eq!(m_attrs["url"], "https://example.com/photo.png");
20052    }
20053
20054    #[test]
20055    fn issue_490_multiple_hardbreaks_then_media_single() {
20056        // Paragraph with multiple hardBreaks, then mediaSingle.
20057        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20058          {"type":"listItem","content":[
20059            {"type":"paragraph","content":[
20060              {"type":"text","text":"line one"},
20061              {"type":"hardBreak"},
20062              {"type":"text","text":"line two"},
20063              {"type":"hardBreak"}
20064            ]},
20065            {"type":"mediaSingle","attrs":{"layout":"center"},
20066             "content":[{"type":"media","attrs":{
20067               "type":"file","id":"multi-hb","collection":"col-m","width":320,"height":240
20068             }}]}
20069          ]}
20070        ]}]}"#;
20071        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20072        let md = adf_to_markdown(&doc).unwrap();
20073        let rt = markdown_to_adf(&md).unwrap();
20074
20075        let item = &rt.content[0].content.as_ref().unwrap()[0];
20076        let children = item.content.as_ref().unwrap();
20077        assert_eq!(children.len(), 2, "expected paragraph + mediaSingle");
20078        assert_eq!(children[0].node_type, "paragraph");
20079        assert_eq!(children[1].node_type, "mediaSingle");
20080        let media = &children[1].content.as_ref().unwrap()[0];
20081        assert_eq!(media.attrs.as_ref().unwrap()["id"], "multi-hb");
20082    }
20083
20084    // ── Issue #525: listItem localId dropped when content includes mediaSingle ──
20085
20086    #[test]
20087    fn issue_525_listitem_localid_with_mediasingle_roundtrip() {
20088        // Exact reproducer from issue #525: listItem with UUID localId whose
20089        // content includes mediaSingle + paragraph + nested bulletList.
20090        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"}]}]}]}]}]}]}"#;
20091        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20092        let md = adf_to_markdown(&doc).unwrap();
20093        let rt = markdown_to_adf(&md).unwrap();
20094
20095        let list = &rt.content[0];
20096        assert_eq!(list.node_type, "bulletList");
20097        let item = &list.content.as_ref().unwrap()[0];
20098        // The localId must be preserved on the listItem.
20099        let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20100        assert_eq!(
20101            item_attrs["localId"], "aabbccdd-1234-5678-abcd-000000000001",
20102            "listItem localId must survive round-trip"
20103        );
20104        let children = item.content.as_ref().unwrap();
20105        assert_eq!(
20106            children.len(),
20107            3,
20108            "expected mediaSingle + paragraph + bulletList"
20109        );
20110        assert_eq!(children[0].node_type, "mediaSingle");
20111        assert_eq!(children[1].node_type, "paragraph");
20112        assert_eq!(children[2].node_type, "bulletList");
20113    }
20114
20115    #[test]
20116    fn issue_525_listitem_localid_with_mediasingle_only() {
20117        // Minimal case: listItem with localId whose sole child is mediaSingle.
20118        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20119          {"type":"listItem","attrs":{"localId":"li-media-only"},"content":[
20120            {"type":"mediaSingle","attrs":{"layout":"center"},
20121             "content":[{"type":"media","attrs":{"type":"file","id":"m-001","collection":"c1","height":50,"width":100}}]}
20122          ]}
20123        ]}]}"#;
20124        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20125        let md = adf_to_markdown(&doc).unwrap();
20126        let rt = markdown_to_adf(&md).unwrap();
20127
20128        let item = &rt.content[0].content.as_ref().unwrap()[0];
20129        let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20130        assert_eq!(
20131            item_attrs["localId"], "li-media-only",
20132            "listItem localId must survive when sole child is mediaSingle"
20133        );
20134        assert_eq!(item.content.as_ref().unwrap()[0].node_type, "mediaSingle");
20135    }
20136
20137    #[test]
20138    fn issue_525_listitem_localid_with_external_media() {
20139        // External image (URL-based) as first child with listItem localId.
20140        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20141          {"type":"listItem","attrs":{"localId":"li-ext-media"},"content":[
20142            {"type":"mediaSingle","attrs":{"layout":"center"},
20143             "content":[{"type":"media","attrs":{"type":"external","url":"https://example.com/img.png","alt":"photo"}}]}
20144          ]}
20145        ]}]}"#;
20146        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20147        let md = adf_to_markdown(&doc).unwrap();
20148        let rt = markdown_to_adf(&md).unwrap();
20149
20150        let item = &rt.content[0].content.as_ref().unwrap()[0];
20151        let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20152        assert_eq!(
20153            item_attrs["localId"], "li-ext-media",
20154            "listItem localId must survive with external mediaSingle"
20155        );
20156    }
20157
20158    #[test]
20159    fn issue_525_listitem_localid_with_mediasingle_in_ordered_list() {
20160        // Same bug in an ordered list.
20161        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
20162          {"type":"listItem","attrs":{"localId":"li-ord-media"},"content":[
20163            {"type":"mediaSingle","attrs":{"layout":"center","width":200,"widthType":"pixel"},
20164             "content":[{"type":"media","attrs":{"type":"file","id":"ord-m-001","collection":"col-ord","height":80,"width":160}}]},
20165            {"type":"paragraph","content":[{"type":"text","text":"ordered item text"}]}
20166          ]}
20167        ]}]}"#;
20168        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20169        let md = adf_to_markdown(&doc).unwrap();
20170        let rt = markdown_to_adf(&md).unwrap();
20171
20172        let item = &rt.content[0].content.as_ref().unwrap()[0];
20173        let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20174        assert_eq!(
20175            item_attrs["localId"], "li-ord-media",
20176            "listItem localId must survive in ordered list with mediaSingle"
20177        );
20178        let children = item.content.as_ref().unwrap();
20179        assert_eq!(children[0].node_type, "mediaSingle");
20180        assert_eq!(children[1].node_type, "paragraph");
20181    }
20182
20183    #[test]
20184    fn issue_525_jfm_localid_on_mediasingle_line_parses_correctly() {
20185        // Verify JFM→ADF: trailing {localId=...} on a mediaSingle line
20186        // is assigned to the listItem, not the media node.
20187        let md = "- ![](){type=file id=test-525 collection=col height=100 width=200 mediaWidth=100 widthType=pixel} {localId=li-jfm-525}\n";
20188        let doc = markdown_to_adf(md).unwrap();
20189
20190        let item = &doc.content[0].content.as_ref().unwrap()[0];
20191        let item_attrs = item
20192            .attrs
20193            .as_ref()
20194            .expect("listItem attrs must be present from JFM");
20195        assert_eq!(item_attrs["localId"], "li-jfm-525");
20196        assert_eq!(item.content.as_ref().unwrap()[0].node_type, "mediaSingle");
20197    }
20198
20199    #[test]
20200    fn issue_525_encoding_emits_localid_on_mediasingle_line() {
20201        // Verify the ADF→JFM encoding: localId appears on the same line
20202        // as the mediaSingle image syntax.
20203        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20204          {"type":"listItem","attrs":{"localId":"li-emit-check"},"content":[
20205            {"type":"mediaSingle","attrs":{"layout":"center"},
20206             "content":[{"type":"media","attrs":{"type":"file","id":"m-emit","collection":"c-emit","height":10,"width":20}}]}
20207          ]}
20208        ]}]}"#;
20209        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20210        let md = adf_to_markdown(&doc).unwrap();
20211        assert!(
20212            md.contains("{localId=li-emit-check}"),
20213            "expected localId in JFM output, got: {md}"
20214        );
20215        // The localId must be on the same line as the image
20216        for line in md.lines() {
20217            if line.contains("![") {
20218                assert!(
20219                    line.contains("localId=li-emit-check"),
20220                    "localId must be on the same line as the image: {line}"
20221                );
20222            }
20223        }
20224    }
20225
20226    // ── Placeholder node tests ────────────────────────────────────
20227
20228    #[test]
20229    fn adf_placeholder_to_markdown() {
20230        let doc = AdfDocument {
20231            version: 1,
20232            doc_type: "doc".to_string(),
20233            content: vec![AdfNode::paragraph(vec![AdfNode::placeholder(
20234                "Type something here",
20235            )])],
20236        };
20237        let md = adf_to_markdown(&doc).unwrap();
20238        assert!(
20239            md.contains(":placeholder[Type something here]"),
20240            "expected :placeholder directive, got: {md}"
20241        );
20242    }
20243
20244    #[test]
20245    fn markdown_placeholder_to_adf() {
20246        let doc = markdown_to_adf("Before :placeholder[Enter name] after").unwrap();
20247        let content = doc.content[0].content.as_ref().unwrap();
20248        assert_eq!(content[1].node_type, "placeholder");
20249        let attrs = content[1].attrs.as_ref().unwrap();
20250        assert_eq!(attrs["text"], "Enter name");
20251    }
20252
20253    #[test]
20254    fn placeholder_round_trip() {
20255        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"placeholder","attrs":{"text":"Type something here"}}]}]}"#;
20256        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20257        let md = adf_to_markdown(&doc).unwrap();
20258        let rt = markdown_to_adf(&md).unwrap();
20259        let content = rt.content[0].content.as_ref().unwrap();
20260        assert_eq!(content.len(), 1);
20261        assert_eq!(content[0].node_type, "placeholder");
20262        let attrs = content[0].attrs.as_ref().unwrap();
20263        assert_eq!(attrs["text"], "Type something here");
20264    }
20265
20266    #[test]
20267    fn placeholder_empty_text() {
20268        let doc = AdfDocument {
20269            version: 1,
20270            doc_type: "doc".to_string(),
20271            content: vec![AdfNode::paragraph(vec![AdfNode::placeholder("")])],
20272        };
20273        let md = adf_to_markdown(&doc).unwrap();
20274        assert!(
20275            md.contains(":placeholder[]"),
20276            "expected empty placeholder directive, got: {md}"
20277        );
20278        let rt = markdown_to_adf(&md).unwrap();
20279        let content = rt.content[0].content.as_ref().unwrap();
20280        assert_eq!(content[0].node_type, "placeholder");
20281        assert_eq!(content[0].attrs.as_ref().unwrap()["text"], "");
20282    }
20283
20284    #[test]
20285    fn placeholder_with_surrounding_text() {
20286        let md = "Click :placeholder[here] to continue\n";
20287        let doc = markdown_to_adf(md).unwrap();
20288        let content = doc.content[0].content.as_ref().unwrap();
20289        assert_eq!(content[0].text.as_deref(), Some("Click "));
20290        assert_eq!(content[1].node_type, "placeholder");
20291        assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "here");
20292        assert_eq!(content[2].text.as_deref(), Some(" to continue"));
20293    }
20294
20295    #[test]
20296    fn placeholder_missing_attrs() {
20297        // Placeholder node with no attrs should not panic
20298        let doc = AdfDocument {
20299            version: 1,
20300            doc_type: "doc".to_string(),
20301            content: vec![AdfNode::paragraph(vec![AdfNode {
20302                node_type: "placeholder".to_string(),
20303                attrs: None,
20304                content: None,
20305                text: None,
20306                marks: None,
20307                local_id: None,
20308                parameters: None,
20309            }])],
20310        };
20311        let md = adf_to_markdown(&doc).unwrap();
20312        // With no attrs, nothing is emitted for the placeholder
20313        assert!(!md.contains("placeholder"));
20314    }
20315
20316    // Issue #446: mention in table+list loses id and misplaces localId
20317    #[test]
20318    fn mention_in_table_bullet_list_preserves_id_and_local_id() {
20319        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":" "}]}]}]}]}]}]}]}"#;
20320        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20321        let md = adf_to_markdown(&doc).unwrap();
20322        let rt = markdown_to_adf(&md).unwrap();
20323
20324        // Navigate: doc → table → tableRow → tableCell → bulletList → listItem → paragraph
20325        let cell = &rt.content[0].content.as_ref().unwrap()[0]
20326            .content
20327            .as_ref()
20328            .unwrap()[0];
20329        let list = &cell.content.as_ref().unwrap()[0];
20330        let list_item = &list.content.as_ref().unwrap()[0];
20331
20332        // listItem must NOT have a localId attribute
20333        assert!(
20334            list_item
20335                .attrs
20336                .as_ref()
20337                .and_then(|a| a.get("localId"))
20338                .is_none(),
20339            "localId should stay on the mention, not the listItem"
20340        );
20341
20342        let para = &list_item.content.as_ref().unwrap()[0];
20343        let inlines = para.content.as_ref().unwrap();
20344
20345        // Should have: text("prefix text "), mention, text(" ")
20346        assert_eq!(inlines.len(), 3, "expected 3 inline nodes, got {inlines:?}");
20347
20348        assert_eq!(inlines[0].node_type, "text");
20349        assert_eq!(inlines[0].text.as_deref(), Some("prefix text "));
20350
20351        assert_eq!(inlines[1].node_type, "mention");
20352        let mention_attrs = inlines[1].attrs.as_ref().unwrap();
20353        assert_eq!(
20354            mention_attrs["id"], "aabbccdd11223344aabbccdd",
20355            "mention id must be preserved"
20356        );
20357        assert_eq!(
20358            mention_attrs["localId"], "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
20359            "mention localId must be preserved"
20360        );
20361        assert_eq!(mention_attrs["text"], "@Alice Example");
20362
20363        assert_eq!(inlines[2].node_type, "text");
20364        assert_eq!(inlines[2].text.as_deref(), Some(" "));
20365    }
20366
20367    #[test]
20368    fn mention_in_bullet_list_preserves_id_and_local_id() {
20369        // Same bug outside of a table context
20370        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":" "}]}]}]}]}"#;
20371        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20372        let md = adf_to_markdown(&doc).unwrap();
20373        let rt = markdown_to_adf(&md).unwrap();
20374
20375        let list_item = &rt.content[0].content.as_ref().unwrap()[0];
20376        assert!(
20377            list_item
20378                .attrs
20379                .as_ref()
20380                .and_then(|a| a.get("localId"))
20381                .is_none(),
20382            "localId should stay on the mention, not the listItem"
20383        );
20384
20385        let para = &list_item.content.as_ref().unwrap()[0];
20386        let inlines = para.content.as_ref().unwrap();
20387        assert_eq!(inlines[0].node_type, "mention");
20388        let mention_attrs = inlines[0].attrs.as_ref().unwrap();
20389        assert_eq!(mention_attrs["id"], "user123");
20390        assert_eq!(
20391            mention_attrs["localId"],
20392            "11111111-2222-3333-4444-555555555555"
20393        );
20394    }
20395
20396    #[test]
20397    fn mention_in_ordered_list_preserves_id_and_local_id() {
20398        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"}}]}]}]}]}"#;
20399        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20400        let md = adf_to_markdown(&doc).unwrap();
20401        let rt = markdown_to_adf(&md).unwrap();
20402
20403        let list_item = &rt.content[0].content.as_ref().unwrap()[0];
20404        assert!(
20405            list_item
20406                .attrs
20407                .as_ref()
20408                .and_then(|a| a.get("localId"))
20409                .is_none(),
20410            "localId should stay on the mention, not the listItem"
20411        );
20412
20413        let para = &list_item.content.as_ref().unwrap()[0];
20414        let inlines = para.content.as_ref().unwrap();
20415        assert_eq!(inlines[1].node_type, "mention");
20416        let mention_attrs = inlines[1].attrs.as_ref().unwrap();
20417        assert_eq!(mention_attrs["id"], "xyz");
20418        assert_eq!(mention_attrs["localId"], "aaaa-bbbb");
20419    }
20420
20421    #[test]
20422    fn list_item_own_local_id_with_mention_both_preserved() {
20423        // When a listItem has its own localId AND contains a mention with localId,
20424        // both should be preserved independently.
20425        let md = "- hello :mention[@Eve]{id=e1 localId=mention-lid} {localId=item-lid}\n";
20426        let doc = markdown_to_adf(md).unwrap();
20427        let list_item = &doc.content[0].content.as_ref().unwrap()[0];
20428
20429        // listItem should have its own localId
20430        let item_attrs = list_item.attrs.as_ref().unwrap();
20431        assert_eq!(item_attrs["localId"], "item-lid");
20432
20433        // mention should have its own localId
20434        let para = &list_item.content.as_ref().unwrap()[0];
20435        let inlines = para.content.as_ref().unwrap();
20436        let mention = inlines.iter().find(|n| n.node_type == "mention").unwrap();
20437        let mention_attrs = mention.attrs.as_ref().unwrap();
20438        assert_eq!(mention_attrs["id"], "e1");
20439        assert_eq!(mention_attrs["localId"], "mention-lid");
20440    }
20441
20442    #[test]
20443    fn extract_trailing_local_id_ignores_directive_attrs() {
20444        // Directly test the helper: a line ending with a directive's {…}
20445        // should NOT be treated as a trailing localId.
20446        let line = "text :mention[@X]{id=abc localId=uuid}";
20447        let (text, lid, plid) = extract_trailing_local_id(line);
20448        assert_eq!(text, line, "text should be unchanged");
20449        assert!(
20450            lid.is_none(),
20451            "should not extract localId from directive attrs"
20452        );
20453        assert!(plid.is_none());
20454    }
20455
20456    #[test]
20457    fn extract_trailing_local_id_matches_standalone_block() {
20458        // A standalone trailing {localId=…} separated by whitespace should still work.
20459        let line = "some text {localId=abc-123}";
20460        let (text, lid, plid) = extract_trailing_local_id(line);
20461        assert_eq!(text, "some text");
20462        assert_eq!(lid.as_deref(), Some("abc-123"));
20463        assert!(plid.is_none());
20464    }
20465
20466    // --- Issue #454: literal newline in text node inside listItem paragraph ---
20467
20468    #[test]
20469    fn newline_in_text_node_roundtrips_in_bullet_list() {
20470        // A text node containing a literal \n inside a bullet list item
20471        // must round-trip as a single text node with the embedded newline
20472        // preserved, not split into multiple paragraphs or hardBreak nodes.
20473        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"}]}]}]}]}"#;
20474        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20475        let md = adf_to_markdown(&doc).unwrap();
20476        let rt = markdown_to_adf(&md).unwrap();
20477
20478        // Should still be a single bulletList with one listItem
20479        assert_eq!(rt.content.len(), 1);
20480        let list = &rt.content[0];
20481        assert_eq!(list.node_type, "bulletList");
20482        let items = list.content.as_ref().unwrap();
20483        assert_eq!(items.len(), 1);
20484
20485        // The listItem should have exactly one paragraph child
20486        let item_content = items[0].content.as_ref().unwrap();
20487        assert_eq!(
20488            item_content.len(),
20489            1,
20490            "listItem should have exactly one paragraph"
20491        );
20492        assert_eq!(item_content[0].node_type, "paragraph");
20493
20494        // The embedded newline must survive as a single text node
20495        let inlines = item_content[0].content.as_ref().unwrap();
20496        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20497        assert_eq!(
20498            types,
20499            vec!["text", "hardBreak", "text"],
20500            "embedded newline should stay in a single text node, not produce extra hardBreaks"
20501        );
20502        assert_eq!(
20503            inlines[2].text.as_deref(),
20504            Some("first command\nsecond command")
20505        );
20506    }
20507
20508    #[test]
20509    fn newline_in_text_node_roundtrips_in_ordered_list() {
20510        // Same as above but in an ordered list.
20511        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"}]}]}]}]}"#;
20512        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20513        let md = adf_to_markdown(&doc).unwrap();
20514        let rt = markdown_to_adf(&md).unwrap();
20515
20516        let list = &rt.content[0];
20517        assert_eq!(list.node_type, "orderedList");
20518        let items = list.content.as_ref().unwrap();
20519        assert_eq!(items.len(), 1);
20520
20521        let item_content = items[0].content.as_ref().unwrap();
20522        assert_eq!(item_content.len(), 1);
20523        assert_eq!(item_content[0].node_type, "paragraph");
20524
20525        let inlines = item_content[0].content.as_ref().unwrap();
20526        assert_eq!(inlines.len(), 1);
20527        assert_eq!(inlines[0].node_type, "text");
20528        assert_eq!(inlines[0].text.as_deref(), Some("first\nsecond"));
20529    }
20530
20531    #[test]
20532    fn newline_in_text_node_roundtrips_in_paragraph() {
20533        // A text node with \n in a top-level paragraph should render as
20534        // escaped \n and round-trip back to a single text node.
20535        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello\nworld"}]}]}"#;
20536        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20537        let md = adf_to_markdown(&doc).unwrap();
20538        assert!(
20539            md.contains("hello\\nworld"),
20540            "newline in text node should render as escaped \\n: {md:?}"
20541        );
20542
20543        let rt = markdown_to_adf(&md).unwrap();
20544        let inlines = rt.content[0].content.as_ref().unwrap();
20545        assert_eq!(inlines.len(), 1);
20546        assert_eq!(inlines[0].text.as_deref(), Some("hello\nworld"));
20547    }
20548
20549    #[test]
20550    fn multiple_newlines_in_text_node_roundtrip() {
20551        // Multiple \n characters should each round-trip within the same text node.
20552        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a\nb\nc"}]}]}]}]}"#;
20553        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20554        let md = adf_to_markdown(&doc).unwrap();
20555        let rt = markdown_to_adf(&md).unwrap();
20556
20557        let item_content = rt.content[0].content.as_ref().unwrap()[0]
20558            .content
20559            .as_ref()
20560            .unwrap();
20561        assert_eq!(item_content.len(), 1);
20562
20563        let inlines = item_content[0].content.as_ref().unwrap();
20564        assert_eq!(inlines.len(), 1);
20565        assert_eq!(inlines[0].text.as_deref(), Some("a\nb\nc"));
20566    }
20567
20568    #[test]
20569    fn newline_in_marked_text_node_roundtrips() {
20570        // A bold text node with \n should round-trip preserving both
20571        // the marks and the embedded newline.
20572        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"bold\ntext","marks":[{"type":"strong"}]}]}]}"#;
20573        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20574        let md = adf_to_markdown(&doc).unwrap();
20575        assert!(
20576            md.contains("**bold\\ntext**"),
20577            "bold text with embedded newline should stay in one marked run: {md:?}"
20578        );
20579
20580        let rt = markdown_to_adf(&md).unwrap();
20581        let inlines = rt.content[0].content.as_ref().unwrap();
20582        assert_eq!(inlines.len(), 1);
20583        assert_eq!(inlines[0].text.as_deref(), Some("bold\ntext"));
20584        assert!(inlines[0]
20585            .marks
20586            .as_ref()
20587            .unwrap()
20588            .iter()
20589            .any(|m| m.mark_type == "strong"));
20590    }
20591
20592    #[test]
20593    fn trailing_newline_in_text_node_roundtrips() {
20594        // A text node ending with \n should round-trip preserving the
20595        // trailing newline.
20596        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"trailing\n"}]}]}"#;
20597        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20598        let md = adf_to_markdown(&doc).unwrap();
20599        assert!(
20600            md.contains("trailing\\n"),
20601            "trailing newline should be escaped: {md:?}"
20602        );
20603
20604        let rt = markdown_to_adf(&md).unwrap();
20605        let inlines = rt.content[0].content.as_ref().unwrap();
20606        assert_eq!(inlines.len(), 1);
20607        assert_eq!(inlines[0].text.as_deref(), Some("trailing\n"));
20608    }
20609
20610    #[test]
20611    fn hardbreak_and_embedded_newline_are_distinct() {
20612        // A hardBreak node and an embedded \n in a text node must not be
20613        // conflated — each must round-trip to its original form.
20614        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"}]}]}"#;
20615        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20616        let md = adf_to_markdown(&doc).unwrap();
20617        let rt = markdown_to_adf(&md).unwrap();
20618
20619        let inlines = rt.content[0].content.as_ref().unwrap();
20620        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20621        assert_eq!(
20622            types,
20623            vec!["text", "hardBreak", "text", "hardBreak", "text"]
20624        );
20625        assert_eq!(inlines[0].text.as_deref(), Some("before"));
20626        assert_eq!(inlines[2].text.as_deref(), Some("mid\ndle"));
20627        assert_eq!(inlines[4].text.as_deref(), Some("after"));
20628    }
20629
20630    // ---- Issue #472 tests ----
20631
20632    #[test]
20633    fn issue_472_bullet_list_trailing_hardbreak_roundtrips() {
20634        // Issue #472: trailing hardBreak at end of listItem paragraph must
20635        // not split the parent bulletList on round-trip.
20636        let adf_json = r#"{"version":1,"type":"doc","content":[
20637          {"type":"bulletList","content":[
20638            {"type":"listItem","content":[
20639              {"type":"paragraph","content":[
20640                {"type":"text","text":"First item"},
20641                {"type":"hardBreak"}
20642              ]}
20643            ]},
20644            {"type":"listItem","content":[
20645              {"type":"paragraph","content":[
20646                {"type":"text","text":"Second item"}
20647              ]}
20648            ]}
20649          ]}
20650        ]}"#;
20651        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20652        let md = adf_to_markdown(&doc).unwrap();
20653        let rt = markdown_to_adf(&md).unwrap();
20654
20655        // Must remain a single bulletList
20656        assert_eq!(
20657            rt.content.len(),
20658            1,
20659            "Should be 1 block (bulletList), got {}",
20660            rt.content.len()
20661        );
20662        assert_eq!(rt.content[0].node_type, "bulletList");
20663        let items = rt.content[0].content.as_ref().unwrap();
20664        assert_eq!(
20665            items.len(),
20666            2,
20667            "Should have 2 listItems, got {}",
20668            items.len()
20669        );
20670
20671        // First item: text + hardBreak (trailing)
20672        let p1 = items[0].content.as_ref().unwrap()[0]
20673            .content
20674            .as_ref()
20675            .unwrap();
20676        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
20677        assert_eq!(types1, vec!["text", "hardBreak"]);
20678        assert_eq!(p1[0].text.as_deref(), Some("First item"));
20679
20680        // Second item: text only
20681        let p2 = items[1].content.as_ref().unwrap()[0]
20682            .content
20683            .as_ref()
20684            .unwrap();
20685        assert_eq!(p2[0].text.as_deref(), Some("Second item"));
20686    }
20687
20688    #[test]
20689    fn issue_472_ordered_list_trailing_hardbreak_roundtrips() {
20690        // Ordered list variant of issue #472.
20691        let adf_json = r#"{"version":1,"type":"doc","content":[
20692          {"type":"orderedList","attrs":{"order":1},"content":[
20693            {"type":"listItem","content":[
20694              {"type":"paragraph","content":[
20695                {"type":"text","text":"Alpha"},
20696                {"type":"hardBreak"}
20697              ]}
20698            ]},
20699            {"type":"listItem","content":[
20700              {"type":"paragraph","content":[
20701                {"type":"text","text":"Beta"}
20702              ]}
20703            ]}
20704          ]}
20705        ]}"#;
20706        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20707        let md = adf_to_markdown(&doc).unwrap();
20708        let rt = markdown_to_adf(&md).unwrap();
20709
20710        assert_eq!(rt.content.len(), 1);
20711        assert_eq!(rt.content[0].node_type, "orderedList");
20712        let items = rt.content[0].content.as_ref().unwrap();
20713        assert_eq!(items.len(), 2);
20714
20715        let p1 = items[0].content.as_ref().unwrap()[0]
20716            .content
20717            .as_ref()
20718            .unwrap();
20719        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
20720        assert_eq!(types1, vec!["text", "hardBreak"]);
20721        assert_eq!(p1[0].text.as_deref(), Some("Alpha"));
20722    }
20723
20724    #[test]
20725    fn issue_472_trailing_hardbreak_jfm_no_blank_line() {
20726        // The rendered JFM must not contain a blank line after the
20727        // trailing hardBreak — that would split the list.
20728        let adf_json = r#"{"version":1,"type":"doc","content":[
20729          {"type":"bulletList","content":[
20730            {"type":"listItem","content":[
20731              {"type":"paragraph","content":[
20732                {"type":"text","text":"Hello"},
20733                {"type":"hardBreak"}
20734              ]}
20735            ]},
20736            {"type":"listItem","content":[
20737              {"type":"paragraph","content":[
20738                {"type":"text","text":"World"}
20739              ]}
20740            ]}
20741          ]}
20742        ]}"#;
20743        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20744        let md = adf_to_markdown(&doc).unwrap();
20745
20746        // Should produce "- Hello\\n- World\n" (no blank line between items).
20747        assert_eq!(md, "- Hello\\\n- World\n");
20748    }
20749
20750    #[test]
20751    fn issue_472_multiple_trailing_hardbreaks_roundtrip() {
20752        // Multiple trailing hardBreaks at the end of a listItem paragraph.
20753        let adf_json = r#"{"version":1,"type":"doc","content":[
20754          {"type":"bulletList","content":[
20755            {"type":"listItem","content":[
20756              {"type":"paragraph","content":[
20757                {"type":"text","text":"Item"},
20758                {"type":"hardBreak"},
20759                {"type":"hardBreak"}
20760              ]}
20761            ]},
20762            {"type":"listItem","content":[
20763              {"type":"paragraph","content":[
20764                {"type":"text","text":"Next"}
20765              ]}
20766            ]}
20767          ]}
20768        ]}"#;
20769        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20770        let md = adf_to_markdown(&doc).unwrap();
20771        let rt = markdown_to_adf(&md).unwrap();
20772
20773        // Must remain a single bulletList
20774        assert_eq!(rt.content.len(), 1);
20775        assert_eq!(rt.content[0].node_type, "bulletList");
20776        let items = rt.content[0].content.as_ref().unwrap();
20777        assert_eq!(items.len(), 2);
20778
20779        // First item should preserve both hardBreaks
20780        let p1 = items[0].content.as_ref().unwrap()[0]
20781            .content
20782            .as_ref()
20783            .unwrap();
20784        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
20785        assert_eq!(types1, vec!["text", "hardBreak", "hardBreak"]);
20786    }
20787
20788    #[test]
20789    fn issue_472_hardbreak_mid_and_trailing_roundtrip() {
20790        // A hardBreak in the middle AND at the end of a listItem paragraph.
20791        let adf_json = r#"{"version":1,"type":"doc","content":[
20792          {"type":"bulletList","content":[
20793            {"type":"listItem","content":[
20794              {"type":"paragraph","content":[
20795                {"type":"text","text":"Line one"},
20796                {"type":"hardBreak"},
20797                {"type":"text","text":"Line two"},
20798                {"type":"hardBreak"}
20799              ]}
20800            ]},
20801            {"type":"listItem","content":[
20802              {"type":"paragraph","content":[
20803                {"type":"text","text":"Other item"}
20804              ]}
20805            ]}
20806          ]}
20807        ]}"#;
20808        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20809        let md = adf_to_markdown(&doc).unwrap();
20810        let rt = markdown_to_adf(&md).unwrap();
20811
20812        assert_eq!(rt.content.len(), 1);
20813        assert_eq!(rt.content[0].node_type, "bulletList");
20814        let items = rt.content[0].content.as_ref().unwrap();
20815        assert_eq!(items.len(), 2);
20816
20817        let p1 = items[0].content.as_ref().unwrap()[0]
20818            .content
20819            .as_ref()
20820            .unwrap();
20821        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
20822        assert_eq!(types1, vec!["text", "hardBreak", "text", "hardBreak"]);
20823        assert_eq!(p1[0].text.as_deref(), Some("Line one"));
20824        assert_eq!(p1[2].text.as_deref(), Some("Line two"));
20825    }
20826
20827    #[test]
20828    fn issue_472_only_hardbreak_in_listitem_paragraph() {
20829        // Edge case: paragraph contains only a hardBreak, no text.
20830        let adf_json = r#"{"version":1,"type":"doc","content":[
20831          {"type":"bulletList","content":[
20832            {"type":"listItem","content":[
20833              {"type":"paragraph","content":[
20834                {"type":"hardBreak"}
20835              ]}
20836            ]},
20837            {"type":"listItem","content":[
20838              {"type":"paragraph","content":[
20839                {"type":"text","text":"After"}
20840              ]}
20841            ]}
20842          ]}
20843        ]}"#;
20844        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20845        let md = adf_to_markdown(&doc).unwrap();
20846        let rt = markdown_to_adf(&md).unwrap();
20847
20848        // Must remain a single bulletList with 2 items
20849        assert_eq!(rt.content.len(), 1);
20850        assert_eq!(rt.content[0].node_type, "bulletList");
20851        let items = rt.content[0].content.as_ref().unwrap();
20852        assert_eq!(items.len(), 2);
20853    }
20854
20855    #[test]
20856    fn issue_472_three_items_middle_has_trailing_hardbreak() {
20857        // Three-item list where only the middle item has a trailing hardBreak.
20858        let adf_json = r#"{"version":1,"type":"doc","content":[
20859          {"type":"bulletList","content":[
20860            {"type":"listItem","content":[
20861              {"type":"paragraph","content":[
20862                {"type":"text","text":"First"}
20863              ]}
20864            ]},
20865            {"type":"listItem","content":[
20866              {"type":"paragraph","content":[
20867                {"type":"text","text":"Second"},
20868                {"type":"hardBreak"}
20869              ]}
20870            ]},
20871            {"type":"listItem","content":[
20872              {"type":"paragraph","content":[
20873                {"type":"text","text":"Third"}
20874              ]}
20875            ]}
20876          ]}
20877        ]}"#;
20878        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20879        let md = adf_to_markdown(&doc).unwrap();
20880        let rt = markdown_to_adf(&md).unwrap();
20881
20882        assert_eq!(rt.content.len(), 1);
20883        assert_eq!(rt.content[0].node_type, "bulletList");
20884        let items = rt.content[0].content.as_ref().unwrap();
20885        assert_eq!(items.len(), 3);
20886        assert_eq!(
20887            items[0].content.as_ref().unwrap()[0]
20888                .content
20889                .as_ref()
20890                .unwrap()[0]
20891                .text
20892                .as_deref(),
20893            Some("First")
20894        );
20895        assert_eq!(
20896            items[2].content.as_ref().unwrap()[0]
20897                .content
20898                .as_ref()
20899                .unwrap()[0]
20900                .text
20901                .as_deref(),
20902            Some("Third")
20903        );
20904    }
20905
20906    // ── Issue #494: trailing space-only text node after hardBreak ────
20907
20908    #[test]
20909    fn issue_494_space_after_hardbreak_roundtrip() {
20910        // The original reproducer from issue #494: a single space text
20911        // node following a hardBreak is silently dropped on round-trip.
20912        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
20913          {"type":"text","text":"Some text"},
20914          {"type":"hardBreak"},
20915          {"type":"text","text":" "}
20916        ]}]}"#;
20917        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20918        let md = adf_to_markdown(&doc).unwrap();
20919        let rt = markdown_to_adf(&md).unwrap();
20920        let inlines = rt.content[0].content.as_ref().unwrap();
20921        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20922        assert_eq!(
20923            types,
20924            vec!["text", "hardBreak", "text"],
20925            "space-only text node after hardBreak should survive round-trip"
20926        );
20927        assert_eq!(inlines[2].text.as_deref(), Some(" "));
20928    }
20929
20930    #[test]
20931    fn issue_494_multiple_spaces_after_hardbreak_roundtrip() {
20932        // Multiple spaces after hardBreak should also survive.
20933        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
20934          {"type":"text","text":"Hello"},
20935          {"type":"hardBreak"},
20936          {"type":"text","text":"   "}
20937        ]}]}"#;
20938        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20939        let md = adf_to_markdown(&doc).unwrap();
20940        let rt = markdown_to_adf(&md).unwrap();
20941        let inlines = rt.content[0].content.as_ref().unwrap();
20942        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20943        assert_eq!(
20944            types,
20945            vec!["text", "hardBreak", "text"],
20946            "multi-space text node after hardBreak should survive round-trip"
20947        );
20948        assert_eq!(inlines[2].text.as_deref(), Some("   "));
20949    }
20950
20951    #[test]
20952    fn issue_494_space_then_text_after_hardbreak_roundtrip() {
20953        // Space followed by real text after hardBreak — the space should
20954        // be preserved as part of the text node.
20955        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
20956          {"type":"text","text":"Before"},
20957          {"type":"hardBreak"},
20958          {"type":"text","text":" After"}
20959        ]}]}"#;
20960        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20961        let md = adf_to_markdown(&doc).unwrap();
20962        let rt = markdown_to_adf(&md).unwrap();
20963        let inlines = rt.content[0].content.as_ref().unwrap();
20964        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20965        assert_eq!(types, vec!["text", "hardBreak", "text"]);
20966        assert_eq!(inlines[2].text.as_deref(), Some(" After"));
20967    }
20968
20969    #[test]
20970    fn issue_494_hardbreak_then_space_then_hardbreak_roundtrip() {
20971        // Space sandwiched between two hardBreaks.
20972        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
20973          {"type":"text","text":"A"},
20974          {"type":"hardBreak"},
20975          {"type":"text","text":" "},
20976          {"type":"hardBreak"},
20977          {"type":"text","text":"B"}
20978        ]}]}"#;
20979        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20980        let md = adf_to_markdown(&doc).unwrap();
20981        let rt = markdown_to_adf(&md).unwrap();
20982        let inlines = rt.content[0].content.as_ref().unwrap();
20983        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20984        assert_eq!(
20985            types,
20986            vec!["text", "hardBreak", "text", "hardBreak", "text"],
20987            "space between two hardBreaks should survive round-trip"
20988        );
20989        assert_eq!(inlines[2].text.as_deref(), Some(" "));
20990        assert_eq!(inlines[4].text.as_deref(), Some("B"));
20991    }
20992
20993    #[test]
20994    fn issue_494_trailing_space_hardbreak_style_not_confused() {
20995        // A plain paragraph break (blank line) should still work after
20996        // a line that does NOT end with a hardBreak marker.
20997        let md = "first paragraph\n\nsecond paragraph\n";
20998        let doc = markdown_to_adf(md).unwrap();
20999        assert_eq!(
21000            doc.content.len(),
21001            2,
21002            "blank line should still separate paragraphs"
21003        );
21004    }
21005
21006    #[test]
21007    fn issue_494_space_after_trailing_space_hardbreak_roundtrip() {
21008        // Same bug but with trailing-space style hardBreak (two spaces
21009        // before newline) instead of backslash style.
21010        let md = "line one  \n   \n";
21011        // The above is: "line one" + trailing-space hardBreak + continuation
21012        // line "   " (2-space indent + 1 space content).  The space-only
21013        // continuation should not be treated as a blank paragraph break.
21014        let doc = markdown_to_adf(md).unwrap();
21015        let inlines = doc.content[0].content.as_ref().unwrap();
21016        let has_text_after_break = inlines.iter().any(|n| {
21017            n.node_type == "text"
21018                && n.text
21019                    .as_deref()
21020                    .is_some_and(|t| t.trim().is_empty() && !t.is_empty())
21021        });
21022        assert!(
21023            has_text_after_break || inlines.len() >= 2,
21024            "space-only line after trailing-space hardBreak should be preserved"
21025        );
21026    }
21027
21028    #[test]
21029    fn issue_494_space_after_hardbreak_in_list_item_roundtrip() {
21030        // Exercises the same bug inside a list item context.
21031        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
21032          {"type":"listItem","content":[{"type":"paragraph","content":[
21033            {"type":"text","text":"item"},
21034            {"type":"hardBreak"},
21035            {"type":"text","text":" "}
21036          ]}]}
21037        ]}]}"#;
21038        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21039        let md = adf_to_markdown(&doc).unwrap();
21040        let rt = markdown_to_adf(&md).unwrap();
21041        let list = &rt.content[0];
21042        let item = &list.content.as_ref().unwrap()[0];
21043        let para = &item.content.as_ref().unwrap()[0];
21044        let inlines = para.content.as_ref().unwrap();
21045        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21046        assert_eq!(
21047            types,
21048            vec!["text", "hardBreak", "text"],
21049            "space after hardBreak in list item should survive round-trip"
21050        );
21051        assert_eq!(inlines[2].text.as_deref(), Some(" "));
21052    }
21053
21054    // ── Issue #510: trailing spaces in text node should not become hardBreak ──
21055
21056    #[test]
21057    fn issue_510_trailing_double_space_paragraph_roundtrip() {
21058        // Two trailing spaces in a text node must survive round-trip without
21059        // being converted to a hardBreak or merging the next paragraph.
21060        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"}]}]}"#;
21061        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21062        let md = adf_to_markdown(&doc).unwrap();
21063        let rt = markdown_to_adf(&md).unwrap();
21064
21065        // Must produce two separate paragraphs
21066        assert_eq!(
21067            rt.content.len(),
21068            2,
21069            "should produce two paragraphs, got: {}",
21070            rt.content.len()
21071        );
21072        assert_eq!(rt.content[0].node_type, "paragraph");
21073        assert_eq!(rt.content[1].node_type, "paragraph");
21074
21075        // First paragraph text preserves trailing spaces
21076        let p1 = rt.content[0].content.as_ref().unwrap();
21077        assert_eq!(
21078            p1[0].text.as_deref(),
21079            Some("first paragraph with trailing spaces  "),
21080            "trailing spaces should be preserved in first paragraph"
21081        );
21082
21083        // Second paragraph is intact
21084        let p2 = rt.content[1].content.as_ref().unwrap();
21085        assert_eq!(p2[0].text.as_deref(), Some("second paragraph"));
21086
21087        // No hardBreak nodes should exist
21088        let all_types: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
21089        assert!(
21090            !all_types.contains(&"hardBreak"),
21091            "trailing spaces should not produce hardBreak, got: {all_types:?}"
21092        );
21093    }
21094
21095    #[test]
21096    fn issue_510_trailing_triple_space_roundtrip() {
21097        // Three trailing spaces also must not become a hardBreak.
21098        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"text   "}]},{"type":"paragraph","content":[{"type":"text","text":"next"}]}]}"#;
21099        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21100        let md = adf_to_markdown(&doc).unwrap();
21101        let rt = markdown_to_adf(&md).unwrap();
21102
21103        assert_eq!(rt.content.len(), 2, "should still be two paragraphs");
21104        let p1 = rt.content[0].content.as_ref().unwrap();
21105        assert_eq!(
21106            p1[0].text.as_deref(),
21107            Some("text   "),
21108            "three trailing spaces should be preserved"
21109        );
21110    }
21111
21112    #[test]
21113    fn issue_510_trailing_spaces_with_backslash_roundtrip() {
21114        // Text ending with backslash + trailing spaces: both must survive.
21115        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"end\\  "}]}]}"#;
21116        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21117        let md = adf_to_markdown(&doc).unwrap();
21118        let rt = markdown_to_adf(&md).unwrap();
21119        let p = rt.content[0].content.as_ref().unwrap();
21120        assert_eq!(
21121            p[0].text.as_deref(),
21122            Some("end\\  "),
21123            "backslash + trailing spaces should both survive"
21124        );
21125    }
21126
21127    #[test]
21128    fn issue_510_jfm_contains_escaped_trailing_space() {
21129        // Verify the serializer actually emits the backslash-space escape.
21130        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello  "}]}]}"#;
21131        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21132        let md = adf_to_markdown(&doc).unwrap();
21133        assert!(
21134            md.contains(r"\ "),
21135            "JFM should contain backslash-space escape for trailing spaces, got: {md:?}"
21136        );
21137        // Must NOT end with two plain spaces before newline
21138        for line in md.lines() {
21139            assert!(
21140                !line.ends_with("  "),
21141                "no JFM line should end with two plain spaces, got: {line:?}"
21142            );
21143        }
21144    }
21145
21146    #[test]
21147    fn issue_510_single_trailing_space_not_escaped() {
21148        // A single trailing space should NOT be escaped (not a hardBreak trigger).
21149        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"word "}]}]}"#;
21150        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21151        let md = adf_to_markdown(&doc).unwrap();
21152        assert!(
21153            !md.contains('\\'),
21154            "single trailing space should not be escaped, got: {md:?}"
21155        );
21156        let rt = markdown_to_adf(&md).unwrap();
21157        let p = rt.content[0].content.as_ref().unwrap();
21158        assert_eq!(p[0].text.as_deref(), Some("word "));
21159    }
21160
21161    #[test]
21162    fn issue_510_trailing_spaces_in_heading_roundtrip() {
21163        // Trailing double-spaces in a heading text node should also survive.
21164        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"heading  "}]}]}"#;
21165        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21166        let md = adf_to_markdown(&doc).unwrap();
21167        let rt = markdown_to_adf(&md).unwrap();
21168        let h = rt.content[0].content.as_ref().unwrap();
21169        assert_eq!(
21170            h[0].text.as_deref(),
21171            Some("heading  "),
21172            "trailing spaces in heading should be preserved"
21173        );
21174    }
21175
21176    #[test]
21177    fn issue_510_trailing_spaces_in_list_item_roundtrip() {
21178        // Trailing double-spaces in a bullet list item text node.
21179        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"item  "}]}]}]}]}"#;
21180        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21181        let md = adf_to_markdown(&doc).unwrap();
21182        let rt = markdown_to_adf(&md).unwrap();
21183        let list = &rt.content[0];
21184        let item = &list.content.as_ref().unwrap()[0];
21185        let para = &item.content.as_ref().unwrap()[0];
21186        let inlines = para.content.as_ref().unwrap();
21187        assert_eq!(
21188            inlines[0].text.as_deref(),
21189            Some("item  "),
21190            "trailing spaces in list item should be preserved"
21191        );
21192    }
21193
21194    #[test]
21195    fn issue_510_trailing_spaces_with_bold_mark_roundtrip() {
21196        // Trailing spaces in a bold-marked text node: the closing **
21197        // comes after the spaces, so the line doesn't end with spaces.
21198        // But the escape should still be applied (and be harmless).
21199        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"bold  ","marks":[{"type":"strong"}]}]}]}"#;
21200        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21201        let md = adf_to_markdown(&doc).unwrap();
21202        let rt = markdown_to_adf(&md).unwrap();
21203        let p = rt.content[0].content.as_ref().unwrap();
21204        assert_eq!(
21205            p[0].text.as_deref(),
21206            Some("bold  "),
21207            "trailing spaces in bold text should be preserved"
21208        );
21209    }
21210
21211    #[test]
21212    fn issue_510_hardbreak_between_paragraphs_still_works() {
21213        // Actual hardBreak nodes must still round-trip correctly.
21214        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"line one"},{"type":"hardBreak"},{"type":"text","text":"line two"}]}]}"#;
21215        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21216        let md = adf_to_markdown(&doc).unwrap();
21217        let rt = markdown_to_adf(&md).unwrap();
21218        let inlines = rt.content[0].content.as_ref().unwrap();
21219        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21220        assert_eq!(
21221            types,
21222            vec!["text", "hardBreak", "text"],
21223            "explicit hardBreak should still round-trip"
21224        );
21225    }
21226
21227    #[test]
21228    fn issue_510_all_spaces_text_node_roundtrip() {
21229        // A text node that is entirely spaces (2+) should survive.
21230        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"  "}]}]}"#;
21231        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21232        let md = adf_to_markdown(&doc).unwrap();
21233        let rt = markdown_to_adf(&md).unwrap();
21234        let p = rt.content[0].content.as_ref().unwrap();
21235        assert_eq!(
21236            p[0].text.as_deref(),
21237            Some("  "),
21238            "space-only text node should survive round-trip"
21239        );
21240    }
21241
21242    // ── Issue #522: listItem multi-paragraph merge ──────────────────────
21243
21244    #[test]
21245    fn issue_522_listitem_hardbreak_then_two_paragraphs_roundtrips() {
21246        // The exact reproducer from issue #522: first paragraph has
21247        // hardBreak nodes, followed by two sibling paragraphs.
21248        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"}]}]}]}]}"#;
21249        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21250        let md = adf_to_markdown(&doc).unwrap();
21251        let rt = markdown_to_adf(&md).unwrap();
21252
21253        let items = rt.content[0].content.as_ref().unwrap();
21254        assert_eq!(items.len(), 1);
21255        let children = items[0].content.as_ref().unwrap();
21256        assert_eq!(
21257            children.len(),
21258            3,
21259            "Expected 3 paragraphs in listItem, got {}",
21260            children.len()
21261        );
21262        assert_eq!(children[0].node_type, "paragraph");
21263        assert_eq!(children[1].node_type, "paragraph");
21264        assert_eq!(children[2].node_type, "paragraph");
21265
21266        // Verify the text content of each paragraph
21267        let text1 = children[1].content.as_ref().unwrap()[0]
21268            .text
21269            .as_deref()
21270            .unwrap();
21271        assert_eq!(text1, "second paragraph");
21272        let text2 = children[2].content.as_ref().unwrap()[0]
21273            .text
21274            .as_deref()
21275            .unwrap();
21276        assert_eq!(text2, "third paragraph");
21277    }
21278
21279    #[test]
21280    fn issue_522_ordered_list_hardbreak_then_paragraphs_roundtrips() {
21281        // Same scenario in an ordered list.
21282        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"}]}]}]}]}"#;
21283        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21284        let md = adf_to_markdown(&doc).unwrap();
21285        let rt = markdown_to_adf(&md).unwrap();
21286
21287        let items = rt.content[0].content.as_ref().unwrap();
21288        let children = items[0].content.as_ref().unwrap();
21289        assert_eq!(
21290            children.len(),
21291            3,
21292            "Expected 3 paragraphs in ordered listItem, got {}",
21293            children.len()
21294        );
21295        assert_eq!(children[1].node_type, "paragraph");
21296        assert_eq!(children[2].node_type, "paragraph");
21297        assert_eq!(
21298            children[1].content.as_ref().unwrap()[0]
21299                .text
21300                .as_deref()
21301                .unwrap(),
21302            "second para"
21303        );
21304        assert_eq!(
21305            children[2].content.as_ref().unwrap()[0]
21306                .text
21307                .as_deref()
21308                .unwrap(),
21309            "third para"
21310        );
21311    }
21312
21313    #[test]
21314    fn issue_522_two_paragraphs_without_hardbreak_roundtrips() {
21315        // Two paragraphs without hardBreak — should also remain separate.
21316        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"}]}]}]}]}"#;
21317        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21318        let md = adf_to_markdown(&doc).unwrap();
21319        let rt = markdown_to_adf(&md).unwrap();
21320
21321        let items = rt.content[0].content.as_ref().unwrap();
21322        let children = items[0].content.as_ref().unwrap();
21323        assert_eq!(
21324            children.len(),
21325            2,
21326            "Expected 2 paragraphs in listItem, got {}",
21327            children.len()
21328        );
21329        assert_eq!(children[0].node_type, "paragraph");
21330        assert_eq!(children[1].node_type, "paragraph");
21331    }
21332
21333    #[test]
21334    fn issue_522_paragraph_then_nested_list_no_spurious_blank() {
21335        // A paragraph followed by a nested list should NOT get a blank
21336        // separator (only paragraph-paragraph transitions need one).
21337        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"}]}]}]}]}]}]}"#;
21338        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21339        let md = adf_to_markdown(&doc).unwrap();
21340        // Should not contain a blank indented line between parent text and sub-list
21341        assert!(
21342            !md.contains("  \n  -"),
21343            "No blank separator between paragraph and nested list"
21344        );
21345        let rt = markdown_to_adf(&md).unwrap();
21346
21347        let items = rt.content[0].content.as_ref().unwrap();
21348        let children = items[0].content.as_ref().unwrap();
21349        assert_eq!(children.len(), 2);
21350        assert_eq!(children[0].node_type, "paragraph");
21351        assert_eq!(children[1].node_type, "bulletList");
21352    }
21353
21354    #[test]
21355    fn issue_522_three_paragraphs_no_hardbreak_roundtrips() {
21356        // Three plain paragraphs (no hardBreak) inside a single listItem.
21357        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"}]}]}]}]}"#;
21358        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21359        let md = adf_to_markdown(&doc).unwrap();
21360        let rt = markdown_to_adf(&md).unwrap();
21361
21362        let items = rt.content[0].content.as_ref().unwrap();
21363        let children = items[0].content.as_ref().unwrap();
21364        assert_eq!(
21365            children.len(),
21366            3,
21367            "Expected 3 paragraphs, got {}",
21368            children.len()
21369        );
21370        for (i, child) in children.iter().enumerate() {
21371            assert_eq!(
21372                child.node_type, "paragraph",
21373                "Child {i} should be a paragraph"
21374            );
21375        }
21376    }
21377
21378    #[test]
21379    fn issue_522_multiple_list_items_each_with_paragraphs() {
21380        // Multiple list items, each with multiple paragraphs.
21381        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"}]}]}]}]}"#;
21382        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21383        let md = adf_to_markdown(&doc).unwrap();
21384        let rt = markdown_to_adf(&md).unwrap();
21385
21386        let items = rt.content[0].content.as_ref().unwrap();
21387        assert_eq!(items.len(), 2, "Expected 2 list items");
21388
21389        let item1 = items[0].content.as_ref().unwrap();
21390        assert_eq!(item1.len(), 2, "Item 1 should have 2 paragraphs");
21391
21392        let item2 = items[1].content.as_ref().unwrap();
21393        assert_eq!(item2.len(), 2, "Item 2 should have 2 paragraphs");
21394        // Verify hardBreak is preserved in item2's first paragraph
21395        let item2_p1_inlines = item2[0].content.as_ref().unwrap();
21396        let types: Vec<&str> = item2_p1_inlines
21397            .iter()
21398            .map(|n| n.node_type.as_str())
21399            .collect();
21400        assert_eq!(types, vec!["text", "hardBreak", "text"]);
21401    }
21402
21403    #[test]
21404    fn issue_531_blockquote_hardbreak_then_two_paragraphs_roundtrips() {
21405        // The exact reproducer from issue #531: blockquote with first
21406        // paragraph containing hardBreak nodes, followed by two sibling
21407        // paragraphs.
21408        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"}]}]}]}"#;
21409        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21410        let md = adf_to_markdown(&doc).unwrap();
21411        let rt = markdown_to_adf(&md).unwrap();
21412
21413        let children = rt.content[0].content.as_ref().unwrap();
21414        assert_eq!(
21415            children.len(),
21416            3,
21417            "Expected 3 paragraphs in blockquote, got {}",
21418            children.len()
21419        );
21420        assert_eq!(children[0].node_type, "paragraph");
21421        assert_eq!(children[1].node_type, "paragraph");
21422        assert_eq!(children[2].node_type, "paragraph");
21423
21424        let text1 = children[1].content.as_ref().unwrap()[0]
21425            .text
21426            .as_deref()
21427            .unwrap();
21428        assert_eq!(text1, "second paragraph");
21429        let text2 = children[2].content.as_ref().unwrap()[0]
21430            .text
21431            .as_deref()
21432            .unwrap();
21433        assert_eq!(text2, "third paragraph");
21434    }
21435
21436    #[test]
21437    fn issue_531_blockquote_two_paragraphs_without_hardbreak_roundtrips() {
21438        // Two simple paragraphs inside a blockquote, no hardBreak.
21439        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"}]}]}]}"#;
21440        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21441        let md = adf_to_markdown(&doc).unwrap();
21442        let rt = markdown_to_adf(&md).unwrap();
21443
21444        let children = rt.content[0].content.as_ref().unwrap();
21445        assert_eq!(
21446            children.len(),
21447            2,
21448            "Expected 2 paragraphs in blockquote, got {}",
21449            children.len()
21450        );
21451        assert_eq!(children[0].node_type, "paragraph");
21452        assert_eq!(children[1].node_type, "paragraph");
21453    }
21454
21455    #[test]
21456    fn issue_531_blockquote_three_paragraphs_no_hardbreak_roundtrips() {
21457        // Three paragraphs, none with hardBreak.
21458        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"}]}]}]}"#;
21459        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21460        let md = adf_to_markdown(&doc).unwrap();
21461        let rt = markdown_to_adf(&md).unwrap();
21462
21463        let children = rt.content[0].content.as_ref().unwrap();
21464        assert_eq!(
21465            children.len(),
21466            3,
21467            "Expected 3 paragraphs in blockquote, got {}",
21468            children.len()
21469        );
21470        for child in children {
21471            assert_eq!(child.node_type, "paragraph");
21472        }
21473    }
21474
21475    #[test]
21476    fn issue_531_blockquote_paragraph_then_list_no_spurious_blank() {
21477        // A paragraph followed by a nested list inside a blockquote —
21478        // should NOT insert a blank separator line.
21479        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"}]}]}]}]}]}"#;
21480        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21481        let md = adf_to_markdown(&doc).unwrap();
21482        let rt = markdown_to_adf(&md).unwrap();
21483
21484        let children = rt.content[0].content.as_ref().unwrap();
21485        assert_eq!(children[0].node_type, "paragraph");
21486        assert_eq!(children[1].node_type, "bulletList");
21487    }
21488
21489    #[test]
21490    fn issue_531_blockquote_single_paragraph_unchanged() {
21491        // A single paragraph in a blockquote should remain unchanged.
21492        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"solo"}]}]}]}"#;
21493        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21494        let md = adf_to_markdown(&doc).unwrap();
21495        let rt = markdown_to_adf(&md).unwrap();
21496
21497        let children = rt.content[0].content.as_ref().unwrap();
21498        assert_eq!(children.len(), 1);
21499        assert_eq!(children[0].node_type, "paragraph");
21500        let text = children[0].content.as_ref().unwrap()[0]
21501            .text
21502            .as_deref()
21503            .unwrap();
21504        assert_eq!(text, "solo");
21505    }
21506
21507    // ── Issue #554: marks combined with `code` or with each other ──────
21508
21509    /// Helper: roundtrip an ADF document and assert the marks on the first
21510    /// text node match `expected_marks` (in order).
21511    fn assert_roundtrip_marks(adf_json: &str, expected_marks: &[&str]) {
21512        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21513        let md = adf_to_markdown(&doc).unwrap();
21514        let rt = markdown_to_adf(&md).unwrap();
21515        let node = &rt.content[0].content.as_ref().unwrap()[0];
21516        let mark_types: Vec<&str> = node
21517            .marks
21518            .as_ref()
21519            .expect("should have marks")
21520            .iter()
21521            .map(|m| m.mark_type.as_str())
21522            .collect();
21523        assert_eq!(
21524            mark_types, expected_marks,
21525            "mark order mismatch for md={md}"
21526        );
21527    }
21528
21529    #[test]
21530    fn issue_554_code_and_text_color_preserved() {
21531        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21532          {"type":"text","text":"x","marks":[
21533            {"type":"textColor","attrs":{"color":"#008000"}},
21534            {"type":"code"}
21535          ]}
21536        ]}]}"##;
21537        assert_roundtrip_marks(adf_json, &["textColor", "code"]);
21538    }
21539
21540    #[test]
21541    fn issue_554_code_and_bg_color_preserved() {
21542        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21543          {"type":"text","text":"x","marks":[
21544            {"type":"backgroundColor","attrs":{"color":"#FF0000"}},
21545            {"type":"code"}
21546          ]}
21547        ]}]}"##;
21548        assert_roundtrip_marks(adf_json, &["backgroundColor", "code"]);
21549    }
21550
21551    #[test]
21552    fn issue_554_code_and_subsup_preserved() {
21553        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21554          {"type":"text","text":"x","marks":[
21555            {"type":"subsup","attrs":{"type":"sub"}},
21556            {"type":"code"}
21557          ]}
21558        ]}]}"#;
21559        assert_roundtrip_marks(adf_json, &["subsup", "code"]);
21560    }
21561
21562    #[test]
21563    fn issue_554_code_and_underline_preserved() {
21564        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21565          {"type":"text","text":"x","marks":[
21566            {"type":"underline"},
21567            {"type":"code"}
21568          ]}
21569        ]}]}"#;
21570        assert_roundtrip_marks(adf_json, &["underline", "code"]);
21571    }
21572
21573    #[test]
21574    fn issue_554_code_textcolor_and_underline_preserved() {
21575        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21576          {"type":"text","text":"x","marks":[
21577            {"type":"textColor","attrs":{"color":"#008000"}},
21578            {"type":"underline"},
21579            {"type":"code"}
21580          ]}
21581        ]}]}"##;
21582        assert_roundtrip_marks(adf_json, &["textColor", "underline", "code"]);
21583    }
21584
21585    #[test]
21586    fn issue_554_textcolor_and_underline_preserved() {
21587        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21588          {"type":"text","text":"x","marks":[
21589            {"type":"textColor","attrs":{"color":"#008000"}},
21590            {"type":"underline"}
21591          ]}
21592        ]}]}"##;
21593        assert_roundtrip_marks(adf_json, &["textColor", "underline"]);
21594    }
21595
21596    #[test]
21597    fn issue_554_underline_and_textcolor_preserved_order_swapped() {
21598        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21599          {"type":"text","text":"x","marks":[
21600            {"type":"underline"},
21601            {"type":"textColor","attrs":{"color":"#008000"}}
21602          ]}
21603        ]}]}"##;
21604        // underline appears first, so it should be the OUTER wrapper.
21605        assert_roundtrip_marks(adf_json, &["underline", "textColor"]);
21606    }
21607
21608    #[test]
21609    fn issue_554_textcolor_and_annotation_preserved() {
21610        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21611          {"type":"text","text":"x","marks":[
21612            {"type":"textColor","attrs":{"color":"#008000"}},
21613            {"type":"annotation","attrs":{"id":"abc-123","annotationType":"inlineComment"}}
21614          ]}
21615        ]}]}"##;
21616        assert_roundtrip_marks(adf_json, &["textColor", "annotation"]);
21617    }
21618
21619    #[test]
21620    fn issue_554_bgcolor_and_underline_preserved() {
21621        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21622          {"type":"text","text":"x","marks":[
21623            {"type":"backgroundColor","attrs":{"color":"#FF0000"}},
21624            {"type":"underline"}
21625          ]}
21626        ]}]}"##;
21627        assert_roundtrip_marks(adf_json, &["backgroundColor", "underline"]);
21628    }
21629
21630    #[test]
21631    fn issue_554_subsup_and_underline_preserved() {
21632        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21633          {"type":"text","text":"x","marks":[
21634            {"type":"subsup","attrs":{"type":"sub"}},
21635            {"type":"underline"}
21636          ]}
21637        ]}]}"#;
21638        assert_roundtrip_marks(adf_json, &["subsup", "underline"]);
21639    }
21640
21641    #[test]
21642    fn issue_554_exact_reproducer_full_match() {
21643        // The exact reproducer from issue #554. The byte-for-byte ADF JSON
21644        // must round-trip through `from-adf | to-adf` unchanged.
21645        let adf_json = r##"{
21646          "version": 1,
21647          "type": "doc",
21648          "content": [
21649            {
21650              "type": "paragraph",
21651              "content": [
21652                {"type":"text","text":"Status: ","marks":[{"type":"strong"}]},
21653                {"type":"text","text":"Approved","marks":[
21654                  {"type":"textColor","attrs":{"color":"#008000"}}
21655                ]},
21656                {"type":"text","text":" — ready to proceed"}
21657              ]
21658            }
21659          ]
21660        }"##;
21661        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21662        let md = adf_to_markdown(&doc).unwrap();
21663        assert!(
21664            md.contains(":span[Approved]{color=#008000}"),
21665            "JFM should contain green span: {md}"
21666        );
21667        let rt = markdown_to_adf(&md).unwrap();
21668        // Find the "Approved" text node and verify color is preserved.
21669        let approved = rt.content[0]
21670            .content
21671            .as_ref()
21672            .unwrap()
21673            .iter()
21674            .find(|n| n.text.as_deref() == Some("Approved"))
21675            .expect("Approved text node");
21676        let marks = approved.marks.as_ref().expect("should have marks");
21677        let color_mark = marks
21678            .iter()
21679            .find(|m| m.mark_type == "textColor")
21680            .expect("textColor mark must be preserved");
21681        assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#008000");
21682    }
21683
21684    #[test]
21685    fn issue_554_textcolor_with_code_renders_span_around_code() {
21686        // Verify the rendered JFM uses `:span[`text`]{color=...}` — the
21687        // syntax suggested in the issue.
21688        let doc = AdfDocument {
21689            version: 1,
21690            doc_type: "doc".to_string(),
21691            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
21692                "fn main",
21693                vec![
21694                    AdfMark::text_color("#008000"),
21695                    AdfMark {
21696                        mark_type: "code".to_string(),
21697                        attrs: None,
21698                    },
21699                ],
21700            )])],
21701        };
21702        let md = adf_to_markdown(&doc).unwrap();
21703        assert!(
21704            md.contains(":span[`fn main`]{color=#008000}"),
21705            "expected span-wrapped code, got: {md}"
21706        );
21707    }
21708
21709    #[test]
21710    fn issue_554_underline_with_code_renders_bracketed_around_code() {
21711        let doc = AdfDocument {
21712            version: 1,
21713            doc_type: "doc".to_string(),
21714            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
21715                "fn main",
21716                vec![
21717                    AdfMark::underline(),
21718                    AdfMark {
21719                        mark_type: "code".to_string(),
21720                        attrs: None,
21721                    },
21722                ],
21723            )])],
21724        };
21725        let md = adf_to_markdown(&doc).unwrap();
21726        assert!(
21727            md.contains("[`fn main`]{underline}"),
21728            "expected bracketed-span around code, got: {md}"
21729        );
21730    }
21731
21732    // ── Issue #554 (re-opened): boundary-underscore destroys span directives ──
21733
21734    #[test]
21735    fn issue_554_underscore_adjacent_to_textcolor_span_roundtrip() {
21736        // Reproducer from the re-opened issue: a `_ ` plain-text node followed
21737        // by a textColor span whose text starts with `_` produced JFM that the
21738        // parser saw as an italic delimiter pair, destroying the span and
21739        // losing the textColor mark entirely.
21740        let adf_json = r##"{
21741          "version": 1,
21742          "type": "doc",
21743          "content": [
21744            {
21745              "type": "paragraph",
21746              "content": [
21747                {"type":"text","text":"_ "},
21748                {"type":"text","text":"_Action:*","marks":[
21749                  {"type":"textColor","attrs":{"color":"#008000"}}
21750                ]},
21751                {"type":"text","text":" Complete the setup process."}
21752              ]
21753            }
21754          ]
21755        }"##;
21756        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21757        let md = adf_to_markdown(&doc).unwrap();
21758        // The leading `_` chars must be backslash-escaped so the parser
21759        // doesn't form a false italic pair across the span boundary.
21760        assert!(
21761            md.contains(r"\_ ") && md.contains(r":span[\_Action"),
21762            "underscores at node boundaries should be escaped: {md}"
21763        );
21764        let rt = markdown_to_adf(&md).unwrap();
21765        let para_content = rt.content[0].content.as_ref().unwrap();
21766        // Find the textColor-marked node.
21767        let colored = para_content
21768            .iter()
21769            .find(|n| {
21770                n.marks
21771                    .as_deref()
21772                    .is_some_and(|ms| ms.iter().any(|m| m.mark_type == "textColor"))
21773            })
21774            .expect("textColor node must be preserved");
21775        assert_eq!(colored.text.as_deref(), Some("_Action:*"));
21776        let color_mark = colored
21777            .marks
21778            .as_ref()
21779            .unwrap()
21780            .iter()
21781            .find(|m| m.mark_type == "textColor")
21782            .unwrap();
21783        assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#008000");
21784        // Verify no spurious em mark crept in.
21785        for n in para_content {
21786            if let Some(ms) = n.marks.as_deref() {
21787                assert!(
21788                    !ms.iter().any(|m| m.mark_type == "em"),
21789                    "no em mark should appear, got marks {:?}",
21790                    ms.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
21791                );
21792            }
21793        }
21794    }
21795
21796    #[test]
21797    fn issue_554_underscore_intraword_left_unescaped() {
21798        // Sanity check: ordinary intraword underscores like `do_something_useful`
21799        // should NOT be escaped — escaping would still round-trip correctly,
21800        // but produces noisy backslashes in the JFM output.
21801        let doc = AdfDocument {
21802            version: 1,
21803            doc_type: "doc".to_string(),
21804            content: vec![AdfNode::paragraph(vec![AdfNode::text(
21805                "call do_something_useful now",
21806            )])],
21807        };
21808        let md = adf_to_markdown(&doc).unwrap();
21809        assert!(
21810            md.contains("do_something_useful") && !md.contains(r"do\_something\_useful"),
21811            "intraword underscores should not be escaped: {md}"
21812        );
21813    }
21814
21815    #[test]
21816    fn issue_554_code_underline_then_textcolor_bracketed_outer() {
21817        // Mark order [underline, textColor, code] — bracketed-span outer,
21818        // span inner. Exercises wrap_with_attrs (true, true) !span_before.
21819        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21820          {"type":"text","text":"x","marks":[
21821            {"type":"underline"},
21822            {"type":"textColor","attrs":{"color":"#008000"}},
21823            {"type":"code"}
21824          ]}
21825        ]}]}"##;
21826        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21827        let md = adf_to_markdown(&doc).unwrap();
21828        // Bracketed-span should be the outermost wrapper.
21829        assert!(
21830            md.starts_with('[') && md.contains("underline}"),
21831            "bracketed-span should wrap the span, got: {md}"
21832        );
21833        let rt = markdown_to_adf(&md).unwrap();
21834        let node = &rt.content[0].content.as_ref().unwrap()[0];
21835        let mark_types: Vec<&str> = node
21836            .marks
21837            .as_ref()
21838            .unwrap()
21839            .iter()
21840            .map(|m| m.mark_type.as_str())
21841            .collect();
21842        assert_eq!(mark_types, vec!["underline", "textColor", "code"]);
21843    }
21844
21845    #[test]
21846    fn issue_554_textcolor_underline_link_all_preserved() {
21847        // Mark order [textColor, underline, link] — span outer, bracketed
21848        // wraps the link inside. Exercises the span-wraps-link-with-bracketed
21849        // branch.
21850        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21851          {"type":"text","text":"linked","marks":[
21852            {"type":"textColor","attrs":{"color":"#008000"}},
21853            {"type":"underline"},
21854            {"type":"link","attrs":{"href":"https://example.com"}}
21855          ]}
21856        ]}]}"##;
21857        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21858        let md = adf_to_markdown(&doc).unwrap();
21859        let rt = markdown_to_adf(&md).unwrap();
21860        let node = &rt.content[0].content.as_ref().unwrap()[0];
21861        let mark_types: Vec<&str> = node
21862            .marks
21863            .as_ref()
21864            .unwrap()
21865            .iter()
21866            .map(|m| m.mark_type.as_str())
21867            .collect();
21868        assert_eq!(mark_types, vec!["textColor", "underline", "link"]);
21869    }
21870
21871    #[test]
21872    fn issue_554_underline_textcolor_link_bracketed_outer_link_last() {
21873        // Mark order [underline, textColor, link] — bracketed-span outer of
21874        // both span and link. Exercises the bracketed-wraps-everything branch.
21875        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21876          {"type":"text","text":"linked","marks":[
21877            {"type":"underline"},
21878            {"type":"textColor","attrs":{"color":"#008000"}},
21879            {"type":"link","attrs":{"href":"https://example.com"}}
21880          ]}
21881        ]}]}"##;
21882        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21883        let md = adf_to_markdown(&doc).unwrap();
21884        let rt = markdown_to_adf(&md).unwrap();
21885        let node = &rt.content[0].content.as_ref().unwrap()[0];
21886        let mark_types: Vec<&str> = node
21887            .marks
21888            .as_ref()
21889            .unwrap()
21890            .iter()
21891            .map(|m| m.mark_type.as_str())
21892            .collect();
21893        assert_eq!(mark_types, vec!["underline", "textColor", "link"]);
21894    }
21895
21896    #[test]
21897    fn issue_554_link_underline_textcolor_link_outer() {
21898        // Mark order [link, underline, textColor] — link outermost, wraps a
21899        // bracketed-span that wraps the span. Exercises the link-wraps-
21900        // bracketed-wraps-span branch.
21901        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21902          {"type":"text","text":"linked","marks":[
21903            {"type":"link","attrs":{"href":"https://example.com"}},
21904            {"type":"underline"},
21905            {"type":"textColor","attrs":{"color":"#008000"}}
21906          ]}
21907        ]}]}"##;
21908        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21909        let md = adf_to_markdown(&doc).unwrap();
21910        assert!(
21911            md.starts_with('[') && md.contains("](https://example.com)"),
21912            "link should be outermost, got: {md}"
21913        );
21914        let rt = markdown_to_adf(&md).unwrap();
21915        let node = &rt.content[0].content.as_ref().unwrap()[0];
21916        let mark_types: Vec<&str> = node
21917            .marks
21918            .as_ref()
21919            .unwrap()
21920            .iter()
21921            .map(|m| m.mark_type.as_str())
21922            .collect();
21923        assert_eq!(mark_types, vec!["link", "underline", "textColor"]);
21924    }
21925
21926    #[test]
21927    fn issue_554_trailing_underscore_then_leading_underscore_round_trip() {
21928        // Two adjacent text nodes where the first ends with `_` and the
21929        // second starts with `_` — without escaping, the JFM parser sees
21930        // an `_..._` pair spanning the boundary.
21931        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21932          {"type":"text","text":"end_"},
21933          {"type":"text","text":"_start"}
21934        ]}]}"#;
21935        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21936        let md = adf_to_markdown(&doc).unwrap();
21937        let rt = markdown_to_adf(&md).unwrap();
21938        // Reassemble all text in the paragraph.
21939        let combined: String = rt.content[0]
21940            .content
21941            .as_ref()
21942            .unwrap()
21943            .iter()
21944            .filter_map(|n| n.text.as_deref())
21945            .collect();
21946        assert_eq!(combined, "end__start");
21947        // No node should have an em mark.
21948        for n in rt.content[0].content.as_ref().unwrap() {
21949            if let Some(ms) = n.marks.as_deref() {
21950                assert!(!ms.iter().any(|m| m.mark_type == "em"));
21951            }
21952        }
21953    }
21954}