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 mut inline_nodes = parse_inline(&full_text);
164        // ADF's `heading` content model forbids the `code` mark, but
165        // JFM/CommonMark parses inline-code spans inside `#`-headings. Strip
166        // them here (keeping the text as plain) so we don't build a document
167        // the validator only rejects at write time (issue #1005).
168        if strip_heading_code_marks(&mut inline_nodes) > 0 {
169            warn!(
170                "Stripped inline `code` from heading (ADF forbids code marks on \
171                 headings; kept the text as plain): {}",
172                full_text.trim()
173            );
174        }
175
176        #[allow(clippy::cast_possible_truncation)]
177        Some(AdfNode::heading(level as u8, inline_nodes))
178    }
179
180    fn try_horizontal_rule(&mut self) -> Option<AdfNode> {
181        let line = self.current_line().trim();
182        let is_rule = (line.starts_with("---") && line.chars().all(|c| c == '-'))
183            || (line.starts_with("***") && line.chars().all(|c| c == '*'))
184            || (line.starts_with("___") && line.chars().all(|c| c == '_'));
185
186        if is_rule && line.len() >= 3 {
187            self.advance();
188            Some(AdfNode::rule())
189        } else {
190            None
191        }
192    }
193
194    fn try_code_block(&mut self) -> Result<Option<AdfNode>> {
195        let line = self.current_line();
196        if !is_code_fence_opener(line) {
197            return Ok(None);
198        }
199
200        let language = line[3..].trim();
201        let language = if language == "\"\"" {
202            // Explicit empty language attr encoded as ```""
203            Some(String::new())
204        } else if language.is_empty() {
205            None
206        } else {
207            Some(language.to_string())
208        };
209
210        self.advance();
211        let mut code_lines = Vec::new();
212
213        while !self.at_end() {
214            let line = self.current_line();
215            if line.starts_with("```") {
216                self.advance();
217                break;
218            }
219            code_lines.push(line);
220            self.advance();
221        }
222
223        let code_text = code_lines.join("\n");
224
225        // If the language is "adf-unsupported", deserialize the JSON back to an AdfNode
226        if language.as_deref() == Some("adf-unsupported") {
227            if let Ok(node) = serde_json::from_str::<AdfNode>(&code_text) {
228                return Ok(Some(node));
229            }
230        }
231
232        Ok(Some(AdfNode::code_block(language.as_deref(), &code_text)))
233    }
234
235    fn try_blockquote(&mut self) -> Result<Option<AdfNode>> {
236        let line = self.current_line();
237        if !line.starts_with('>') {
238            return Ok(None);
239        }
240
241        let mut quote_lines = Vec::new();
242        while !self.at_end() {
243            let line = self.current_line();
244            if let Some(rest) = line.strip_prefix("> ") {
245                quote_lines.push(rest);
246                self.advance();
247            } else if let Some(rest) = line.strip_prefix('>') {
248                quote_lines.push(rest);
249                self.advance();
250            } else {
251                break;
252            }
253        }
254
255        let quote_text = quote_lines.join("\n");
256        let mut inner_parser = MarkdownParser::new(&quote_text);
257        let inner_blocks = inner_parser.parse_blocks()?;
258
259        Ok(Some(AdfNode::blockquote(inner_blocks)))
260    }
261
262    fn try_list(&mut self) -> Result<Option<AdfNode>> {
263        let line = self.current_line();
264        let trimmed = line.trim_start();
265
266        let is_bullet =
267            trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ");
268        let ordered_match = parse_ordered_list_marker(trimmed);
269
270        if !is_bullet && ordered_match.is_none() {
271            return Ok(None);
272        }
273
274        if is_bullet {
275            self.parse_bullet_list()
276        } else {
277            let start = ordered_match.map_or(1, |(n, _)| n);
278            self.parse_ordered_list(start)
279        }
280    }
281
282    fn parse_bullet_list(&mut self) -> Result<Option<AdfNode>> {
283        let mut items = Vec::new();
284        let mut is_task_list = false;
285
286        while !self.at_end() {
287            let line = self.current_line();
288            let trimmed = line.trim_start();
289
290            if !(trimmed.starts_with("- ")
291                || trimmed.starts_with("* ")
292                || trimmed.starts_with("+ "))
293            {
294                break;
295            }
296
297            let after_marker = trimmed[2..].trim_start();
298
299            // Detect task list items: - [ ] or - [x]
300            if let Some((state, text)) = try_parse_task_marker(after_marker) {
301                is_task_list = true;
302                self.advance();
303                // Collect hardBreak continuation lines so that a trailing
304                // {localId=…} on the last continuation line is found by
305                // extract_trailing_local_id (issue #507).
306                let mut full_text = text.to_string();
307                self.collect_hardbreak_continuations(&mut full_text);
308                let (item_text, local_id, para_local_id) = extract_trailing_local_id(&full_text);
309                let inline_nodes = parse_inline(item_text);
310                // If a paraLocalId marker is present the original ADF had a
311                // paragraph wrapper around the inline content — restore it
312                // so the round-trip is lossless (issue #478).
313                let content = if let Some(ref plid) = para_local_id {
314                    let mut para = AdfNode::paragraph(inline_nodes);
315                    if plid != "_" {
316                        para.attrs = Some(serde_json::json!({"localId": plid}));
317                    }
318                    vec![para]
319                } else {
320                    inline_nodes
321                };
322                let mut task = AdfNode::task_item(state, content);
323                // Override the placeholder localId if one was parsed
324                if let Some(id) = local_id {
325                    if let Some(ref mut attrs) = task.attrs {
326                        attrs["localId"] = serde_json::Value::String(id);
327                    }
328                }
329                // Collect indented sub-content (e.g. nested task lists
330                // from malformed ADF where taskItem contains taskItem
331                // children directly — issue #489).
332                let mut sub_lines: Vec<String> = Vec::new();
333                while !self.at_end() && self.current_line().starts_with("  ") {
334                    let stripped = &self.current_line()[2..];
335                    sub_lines.push(stripped.to_string());
336                    self.advance();
337                }
338                if !sub_lines.is_empty() {
339                    let sub_text = sub_lines.join("\n");
340                    let mut nested = MarkdownParser::new(&sub_text).parse_blocks()?;
341                    // When the task item has no inline text and its
342                    // sub-content is a single taskList, this is a
343                    // container taskItem from malformed ADF (issue #489).
344                    // Unwrap the taskList so the taskItem children sit
345                    // directly in the container, and drop the spurious
346                    // `state` attr that was injected by the checkbox
347                    // marker.
348                    let is_empty = task.content.as_ref().map_or(true, Vec::is_empty);
349                    if is_empty && nested.len() == 1 && nested[0].node_type == "taskList" {
350                        if let Some(task_items) = nested.remove(0).content {
351                            task.content = Some(task_items);
352                        }
353                        if let Some(ref mut attrs) = task.attrs {
354                            if let Some(obj) = attrs.as_object_mut() {
355                                obj.remove("state");
356                            }
357                        }
358                        items.push(task);
359                    } else {
360                        // Separate nested taskList nodes from other block
361                        // content.  Nested taskLists become sibling children
362                        // of the outer taskList rather than children of this
363                        // taskItem, matching ADF's representation of indented
364                        // sub-lists (issue #506).
365                        let mut sibling_task_lists = Vec::new();
366                        let mut child_nodes = Vec::new();
367                        for n in nested {
368                            if n.node_type == "taskList" {
369                                sibling_task_lists.push(n);
370                            } else {
371                                child_nodes.push(n);
372                            }
373                        }
374                        if !child_nodes.is_empty() {
375                            match task.content {
376                                Some(ref mut content) => content.append(&mut child_nodes),
377                                None => task.content = Some(child_nodes),
378                            }
379                        }
380                        items.push(task);
381                        items.append(&mut sibling_task_lists);
382                    }
383                } else {
384                    items.push(task);
385                }
386            } else {
387                let first_line = &trimmed[2..];
388                self.advance();
389                let mut full_text = first_line.to_string();
390                self.collect_hardbreak_continuations(&mut full_text);
391                let (item_text, local_id, para_local_id) = extract_trailing_local_id(&full_text);
392                // Collect indented sub-content lines (2-space prefix).
393                // This captures both nested lists and continuation
394                // paragraphs that belong to the same list item.
395                let mut sub_lines: Vec<String> = Vec::new();
396                while !self.at_end() {
397                    let next = self.current_line();
398                    if let Some(stripped) = next.strip_prefix("  ") {
399                        sub_lines.push(stripped.to_string());
400                        self.advance();
401                        continue;
402                    }
403                    break;
404                }
405                let item_content =
406                    parse_list_item_first_line(item_text, sub_lines, local_id, para_local_id)?;
407                items.push(item_content);
408            }
409        }
410
411        if items.is_empty() {
412            Ok(None)
413        } else if is_task_list {
414            Ok(Some(AdfNode::task_list(items)))
415        } else {
416            Ok(Some(AdfNode::bullet_list(items)))
417        }
418    }
419
420    fn parse_ordered_list(&mut self, start: u32) -> Result<Option<AdfNode>> {
421        let mut items = Vec::new();
422
423        while !self.at_end() {
424            let line = self.current_line();
425            let trimmed = line.trim_start();
426
427            if let Some((_, rest)) = parse_ordered_list_marker(trimmed) {
428                let first_line = rest.trim_start_matches(|c: char| c.is_ascii_whitespace());
429                self.advance();
430                let mut full_text = first_line.to_string();
431                self.collect_hardbreak_continuations(&mut full_text);
432                let (item_text, local_id, para_local_id) = extract_trailing_local_id(&full_text);
433                // Collect indented sub-content lines (2-space prefix).
434                let mut sub_lines: Vec<String> = Vec::new();
435                while !self.at_end() {
436                    let next = self.current_line();
437                    if let Some(stripped) = next.strip_prefix("  ") {
438                        sub_lines.push(stripped.to_string());
439                        self.advance();
440                        continue;
441                    }
442                    break;
443                }
444                let item_content =
445                    parse_list_item_first_line(item_text, sub_lines, local_id, para_local_id)?;
446                items.push(item_content);
447            } else {
448                break;
449            }
450        }
451
452        if items.is_empty() {
453            Ok(None)
454        } else {
455            let order = if start == 1 { None } else { Some(start) };
456            Ok(Some(AdfNode::ordered_list(items, order)))
457        }
458    }
459
460    fn try_apply_block_attrs(&mut self, node: &mut AdfNode) {
461        if self.at_end() {
462            return;
463        }
464        let line = self.current_line().trim();
465        if !line.starts_with('{') {
466            return;
467        }
468        let Some((_, attrs)) = parse_attrs(line, 0) else {
469            return;
470        };
471
472        let mut marks = Vec::new();
473        if let Some(align) = attrs.get("align") {
474            marks.push(AdfMark::alignment(align));
475        }
476        if let Some(indent) = attrs.get("indent") {
477            if let Ok(level) = indent.parse::<u32>() {
478                marks.push(AdfMark::indentation(level));
479            }
480        }
481        if let Some(mode) = attrs.get("breakout") {
482            let width = attrs
483                .get("breakoutWidth")
484                .and_then(|w| w.parse::<u32>().ok());
485            marks.push(AdfMark::breakout(mode, width));
486        }
487
488        // Parse localId from block attrs
489        let local_id = attrs.get("localId").map(str::to_string);
490
491        // Parse explicit order for orderedList nodes (issue #547).
492        let order = if node.node_type == "orderedList" {
493            attrs.get("order").and_then(|v| v.parse::<u32>().ok())
494        } else {
495            None
496        };
497
498        let has_attrs = !marks.is_empty() || local_id.is_some() || order.is_some();
499        if has_attrs {
500            if !marks.is_empty() {
501                let existing = node.marks.get_or_insert_with(Vec::new);
502                existing.extend(marks);
503            }
504            if let Some(id) = local_id {
505                let node_attrs = node.attrs.get_or_insert_with(|| serde_json::json!({}));
506                node_attrs["localId"] = serde_json::Value::String(id);
507            }
508            if let Some(n) = order {
509                let node_attrs = node.attrs.get_or_insert_with(|| serde_json::json!({}));
510                node_attrs["order"] = serde_json::json!(n);
511            }
512            self.advance(); // consume the attrs line
513        }
514    }
515
516    fn try_container_directive(&mut self) -> Result<Option<AdfNode>> {
517        let line = self.current_line();
518        let Some((d, colon_count)) = try_parse_container_open(line) else {
519            return Ok(None);
520        };
521        self.advance(); // past opening fence
522
523        // Collect inner lines until the matching close fence, tracking nesting
524        let mut inner_lines = Vec::new();
525        let mut depth: usize = 0;
526        while !self.at_end() {
527            let current = self.current_line();
528            if try_parse_container_open(current).is_some() {
529                depth += 1;
530            } else if depth == 0 && is_container_close(current, colon_count) {
531                self.advance(); // past closing fence
532                break;
533            } else if depth > 0 && is_container_close(current, 3) {
534                depth -= 1;
535            }
536            inner_lines.push(current.to_string());
537            self.advance();
538        }
539
540        let inner_text = inner_lines.join("\n");
541
542        let node = match d.name.as_str() {
543            "panel" => {
544                let panel_type = d
545                    .attrs
546                    .as_ref()
547                    .and_then(|a| a.get("type"))
548                    .unwrap_or("info");
549                let inner_blocks = MarkdownParser::new(&inner_text).parse_blocks()?;
550                let mut node = AdfNode::panel(panel_type, inner_blocks);
551                // Pass through custom panel attrs (icon, color)
552                if let Some(ref attrs) = d.attrs {
553                    if let Some(ref mut node_attrs) = node.attrs {
554                        if let Some(icon) = attrs.get("icon") {
555                            node_attrs["panelIcon"] = serde_json::Value::String(icon.to_string());
556                        }
557                        if let Some(color) = attrs.get("color") {
558                            node_attrs["panelColor"] = serde_json::Value::String(color.to_string());
559                        }
560                    }
561                }
562                node
563            }
564            "expand" => {
565                let title = d.attrs.as_ref().and_then(|a| a.get("title"));
566                let inner_blocks = MarkdownParser::new(&inner_text).parse_blocks()?;
567                let mut node = AdfNode::expand(title, inner_blocks);
568                pass_through_expand_params(&d.attrs, &mut node);
569                node
570            }
571            "nested-expand" => {
572                let title = d.attrs.as_ref().and_then(|a| a.get("title"));
573                let inner_blocks = MarkdownParser::new(&inner_text).parse_blocks()?;
574                let mut node = AdfNode::nested_expand(title, inner_blocks);
575                pass_through_expand_params(&d.attrs, &mut node);
576                node
577            }
578            "layout" => {
579                // Parse inner content looking for :::column sub-containers
580                let columns = self.parse_layout_columns(&inner_text)?;
581                AdfNode::layout_section(columns)
582            }
583            "decisions" => {
584                let items = parse_decision_items(&inner_text);
585                AdfNode::decision_list(items)
586            }
587            "table" => {
588                let rows = self.parse_directive_table_rows(&inner_text)?;
589                let mut table_attrs = serde_json::json!({});
590                if let Some(ref attrs) = d.attrs {
591                    if let Some(layout) = attrs.get("layout") {
592                        table_attrs["layout"] = serde_json::Value::String(layout.to_string());
593                    }
594                    if attrs.has_flag("numbered") {
595                        table_attrs["isNumberColumnEnabled"] = serde_json::json!(true);
596                    } else if attrs.get("numbered") == Some("false") {
597                        table_attrs["isNumberColumnEnabled"] = serde_json::json!(false);
598                    }
599                    if let Some(tw) = attrs.get("width") {
600                        if let Some(w) = parse_numeric_attr(tw) {
601                            table_attrs["width"] = w;
602                        }
603                    }
604                    if let Some(local_id) = attrs.get("localId") {
605                        table_attrs["localId"] = serde_json::Value::String(local_id.to_string());
606                    }
607                }
608                if table_attrs == serde_json::json!({}) {
609                    AdfNode::table(rows)
610                } else {
611                    AdfNode::table_with_attrs(rows, table_attrs)
612                }
613            }
614            "extension" => {
615                let ext_type = d.attrs.as_ref().and_then(|a| a.get("type")).unwrap_or("");
616                let ext_key = d.attrs.as_ref().and_then(|a| a.get("key")).unwrap_or("");
617                let inner_blocks = MarkdownParser::new(&inner_text).parse_blocks()?;
618                let mut node = AdfNode::bodied_extension(ext_type, ext_key, inner_blocks);
619                if let (Some(ref dir_attrs), Some(ref mut node_attrs)) = (&d.attrs, &mut node.attrs)
620                {
621                    if let Some(layout) = dir_attrs.get("layout") {
622                        node_attrs["layout"] = serde_json::Value::String(layout.to_string());
623                    }
624                    if let Some(local_id) = dir_attrs.get("localId") {
625                        node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
626                    }
627                    if let Some(params_str) = dir_attrs.get("params") {
628                        if let Ok(params_val) =
629                            serde_json::from_str::<serde_json::Value>(params_str)
630                        {
631                            node_attrs["parameters"] = params_val;
632                        }
633                    }
634                }
635                node
636            }
637            _ => return Ok(None),
638        };
639
640        Ok(Some(node))
641    }
642
643    fn parse_layout_columns(&self, inner_text: &str) -> Result<Vec<AdfNode>> {
644        let mut columns = Vec::new();
645        let mut current_column_lines: Vec<String> = Vec::new();
646        let mut current_width: serde_json::Value = serde_json::json!(50);
647        let mut current_dir_attrs: Option<crate::atlassian::attrs::Attrs> = None;
648        let mut in_column = false;
649        let mut depth: usize = 0;
650
651        let lines: Vec<&str> = inner_text.lines().collect();
652        let mut i = 0;
653
654        while i < lines.len() {
655            let line = lines[i];
656            if let Some((col_d, _)) = try_parse_container_open(line) {
657                if col_d.name == "column" && depth == 0 {
658                    // Flush previous column
659                    if in_column && !current_column_lines.is_empty() {
660                        let col_text = current_column_lines.join("\n");
661                        let blocks = MarkdownParser::new(&col_text).parse_blocks()?;
662                        let mut col = AdfNode::layout_column(current_width.clone(), blocks);
663                        pass_through_local_id(&current_dir_attrs, &mut col);
664                        columns.push(col);
665                        current_column_lines.clear();
666                    }
667                    current_width = col_d
668                        .attrs
669                        .as_ref()
670                        .and_then(|a| a.get("width"))
671                        .and_then(parse_numeric_attr)
672                        .unwrap_or_else(|| serde_json::json!(50));
673                    current_dir_attrs = col_d.attrs;
674                    in_column = true;
675                    i += 1;
676                    continue;
677                }
678                if in_column {
679                    depth += 1;
680                }
681            }
682            if in_column && is_container_close(line, 3) {
683                if depth > 0 {
684                    depth -= 1;
685                    current_column_lines.push(line.to_string());
686                    i += 1;
687                    continue;
688                }
689                // End of column
690                let col_text = current_column_lines.join("\n");
691                let blocks = MarkdownParser::new(&col_text).parse_blocks()?;
692                let mut col = AdfNode::layout_column(current_width.clone(), blocks);
693                pass_through_local_id(&current_dir_attrs, &mut col);
694                columns.push(col);
695                current_column_lines.clear();
696                current_dir_attrs = None;
697                in_column = false;
698                i += 1;
699                continue;
700            }
701            if in_column {
702                current_column_lines.push(line.to_string());
703            }
704            i += 1;
705        }
706
707        // Flush last column if no closing fence
708        if in_column && !current_column_lines.is_empty() {
709            let col_text = current_column_lines.join("\n");
710            let blocks = MarkdownParser::new(&col_text).parse_blocks()?;
711            let mut col = AdfNode::layout_column(current_width, blocks);
712            pass_through_local_id(&current_dir_attrs, &mut col);
713            columns.push(col);
714        }
715
716        Ok(columns)
717    }
718
719    /// Parses `:::tr` / `:::th` / `:::td` sub-containers inside a `:::table` directive.
720    fn parse_directive_table_rows(&self, inner_text: &str) -> Result<Vec<AdfNode>> {
721        debug!(
722            "parse_directive_table_rows: {} lines of inner text",
723            inner_text.lines().count()
724        );
725        let mut rows = Vec::new();
726        let lines: Vec<&str> = inner_text.lines().collect();
727        let mut i = 0;
728
729        while i < lines.len() {
730            let line = lines[i];
731            if let Some((d, _)) = try_parse_container_open(line) {
732                if d.name == "tr" {
733                    let tr_attrs = d.attrs.clone();
734                    i += 1;
735                    let (mut row, next_i) = self.parse_directive_table_row(&lines, i)?;
736                    // Pass through localId from :::tr{localId=...}
737                    if let Some(ref attrs) = tr_attrs {
738                        if let Some(local_id) = attrs.get("localId") {
739                            let row_attrs = row.attrs.get_or_insert_with(|| serde_json::json!({}));
740                            row_attrs["localId"] = serde_json::Value::String(local_id.to_string());
741                        }
742                    }
743                    rows.push(row);
744                    i = next_i;
745                    continue;
746                }
747                if d.name == "caption" {
748                    let dir_attrs = d.attrs.clone();
749                    i += 1;
750                    let mut caption_lines = Vec::new();
751                    while i < lines.len() {
752                        if is_container_close(lines[i], 3) {
753                            i += 1;
754                            break;
755                        }
756                        caption_lines.push(lines[i]);
757                        i += 1;
758                    }
759                    let caption_text = caption_lines.join("\n");
760                    let inline_nodes = parse_inline(&caption_text);
761                    let mut caption = AdfNode::caption(inline_nodes);
762                    pass_through_local_id(&dir_attrs, &mut caption);
763                    rows.push(caption);
764                    continue;
765                }
766            }
767            i += 1;
768        }
769
770        Ok(rows)
771    }
772
773    /// Parses cells within a `:::tr` container until its closing fence.
774    fn parse_directive_table_row(&self, lines: &[&str], start: usize) -> Result<(AdfNode, usize)> {
775        let mut cells = Vec::new();
776        let mut i = start;
777        let mut depth: usize = 0;
778
779        while i < lines.len() {
780            let line = lines[i];
781            if is_container_close(line, 3) {
782                if depth == 0 {
783                    // End of :::tr
784                    i += 1;
785                    break;
786                }
787                depth -= 1;
788                i += 1;
789                continue;
790            }
791            if let Some((d, _)) = try_parse_container_open(line) {
792                if depth == 0 && (d.name == "th" || d.name == "td") {
793                    let is_header = d.name == "th";
794                    let cell_attrs = d.attrs.clone();
795                    i += 1;
796                    let (cell, next_i) =
797                        self.parse_directive_table_cell(lines, i, is_header, cell_attrs)?;
798                    cells.push(cell);
799                    i = next_i;
800                    continue;
801                }
802                depth += 1;
803            }
804            i += 1;
805        }
806
807        if cells.is_empty() {
808            let context = lines[start.saturating_sub(1)..lines.len().min(start + 3)].to_vec();
809            warn!(
810                "Directive table row at line {start} has no cells — \
811                 Confluence requires at least one. Nearby lines: {context:?}"
812            );
813        }
814        debug!("Parsed directive table row: {} cells", cells.len());
815
816        Ok((AdfNode::table_row(cells), i))
817    }
818
819    /// Parses the content of a `:::th` or `:::td` cell until its closing fence.
820    fn parse_directive_table_cell(
821        &self,
822        lines: &[&str],
823        start: usize,
824        is_header: bool,
825        cell_attrs: Option<crate::atlassian::attrs::Attrs>,
826    ) -> Result<(AdfNode, usize)> {
827        let mut cell_lines = Vec::new();
828        let mut i = start;
829        let mut depth: usize = 0;
830
831        while i < lines.len() {
832            let line = lines[i];
833            if try_parse_container_open(line).is_some() {
834                depth += 1;
835            } else if is_container_close(line, 3) {
836                if depth == 0 {
837                    i += 1;
838                    break;
839                }
840                depth -= 1;
841            }
842            cell_lines.push(line.to_string());
843            i += 1;
844        }
845
846        let cell_text = cell_lines.join("\n");
847        let blocks = MarkdownParser::new(&cell_text).parse_blocks()?;
848
849        let adf_attrs = cell_attrs.as_ref().map(build_cell_attrs);
850        let cell_marks = cell_attrs
851            .as_ref()
852            .map(build_border_marks)
853            .unwrap_or_default();
854
855        let cell = if cell_marks.is_empty() {
856            if is_header {
857                if let Some(attrs) = adf_attrs {
858                    AdfNode::table_header_with_attrs(blocks, attrs)
859                } else {
860                    AdfNode::table_header(blocks)
861                }
862            } else if let Some(attrs) = adf_attrs {
863                AdfNode::table_cell_with_attrs(blocks, attrs)
864            } else {
865                AdfNode::table_cell(blocks)
866            }
867        } else if is_header {
868            AdfNode::table_header_with_attrs_and_marks(blocks, adf_attrs, cell_marks)
869        } else {
870            AdfNode::table_cell_with_attrs_and_marks(blocks, adf_attrs, cell_marks)
871        };
872
873        Ok((cell, i))
874    }
875
876    fn try_leaf_directive(&mut self) -> Option<AdfNode> {
877        let line = self.current_line();
878        let d = try_parse_leaf_directive(line)?;
879
880        let node = match d.name.as_str() {
881            "card" => {
882                let content = d.content.as_deref().unwrap_or("");
883                // Prefer the `url` attribute when present; fall back to the
884                // bracketed content.  The attribute form is used when the URL
885                // contains characters that would otherwise break
886                // `::card[URL]` parsing.
887                let url = match d.attrs.as_ref().and_then(|a| a.get("url")) {
888                    Some(u) => u,
889                    None => content,
890                };
891                let mut node = AdfNode::block_card(url);
892                // Pass through layout/width attrs
893                if let Some(ref attrs) = d.attrs {
894                    if let Some(ref mut node_attrs) = node.attrs {
895                        if let Some(layout) = attrs.get("layout") {
896                            node_attrs["layout"] = serde_json::Value::String(layout.to_string());
897                        }
898                        if let Some(width) = attrs.get("width") {
899                            if let Ok(w) = width.parse::<u64>() {
900                                node_attrs["width"] = serde_json::json!(w);
901                            }
902                        }
903                    }
904                }
905                node
906            }
907            "embed" => {
908                let url = d.content.as_deref().unwrap_or("");
909                let layout = d.attrs.as_ref().and_then(|a| a.get("layout"));
910                let original_height = d
911                    .attrs
912                    .as_ref()
913                    .and_then(|a| a.get("originalHeight"))
914                    .and_then(|v| v.parse::<f64>().ok());
915                let width = d
916                    .attrs
917                    .as_ref()
918                    .and_then(|a| a.get("width"))
919                    .and_then(|w| w.parse::<f64>().ok());
920                AdfNode::embed_card(url, layout, original_height, width)
921            }
922            "extension" => {
923                let ext_type = d.attrs.as_ref().and_then(|a| a.get("type")).unwrap_or("");
924                let ext_key = d.attrs.as_ref().and_then(|a| a.get("key")).unwrap_or("");
925                let params = d
926                    .attrs
927                    .as_ref()
928                    .and_then(|a| a.get("params"))
929                    .and_then(|p| serde_json::from_str(p).ok());
930                let mut node = AdfNode::extension(ext_type, ext_key, params);
931                if let (Some(ref dir_attrs), Some(ref mut node_attrs)) = (&d.attrs, &mut node.attrs)
932                {
933                    if let Some(layout) = dir_attrs.get("layout") {
934                        node_attrs["layout"] = serde_json::Value::String(layout.to_string());
935                    }
936                    if let Some(local_id) = dir_attrs.get("localId") {
937                        node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
938                    }
939                }
940                node
941            }
942            "paragraph" => {
943                let mut node = if let Some(ref text) = d.content {
944                    AdfNode::paragraph(parse_inline(text))
945                } else {
946                    AdfNode::paragraph(vec![])
947                };
948                pass_through_local_id(&d.attrs, &mut node);
949                node
950            }
951            _ => return None,
952        };
953
954        self.advance();
955        Some(node)
956    }
957
958    fn try_image(&mut self) -> Option<AdfNode> {
959        let line = self.current_line().trim();
960        let mut node = try_parse_media_single_from_line(line)?;
961        self.advance();
962
963        // Check for a trailing :::caption directive
964        if !self.at_end() {
965            if let Some((d, _)) = try_parse_container_open(self.current_line()) {
966                if d.name == "caption" {
967                    let dir_attrs = d.attrs;
968                    self.advance(); // past :::caption
969                    let mut caption_lines = Vec::new();
970                    while !self.at_end() {
971                        if is_container_close(self.current_line(), 3) {
972                            self.advance(); // past :::
973                            break;
974                        }
975                        caption_lines.push(self.current_line());
976                        self.advance();
977                    }
978                    let caption_text = caption_lines.join("\n");
979                    let inline_nodes = parse_inline(&caption_text);
980                    let mut caption = AdfNode::caption(inline_nodes);
981                    pass_through_local_id(&dir_attrs, &mut caption);
982                    if let Some(ref mut content) = node.content {
983                        content.push(caption);
984                    }
985                }
986            }
987        }
988
989        Some(node)
990    }
991
992    fn try_table(&mut self) -> Result<Option<AdfNode>> {
993        let line = self.current_line();
994        if !line.contains('|') || !line.trim_start().starts_with('|') {
995            return Ok(None);
996        }
997
998        // Peek ahead to check for a separator row (indicates a table)
999        if self.pos + 1 >= self.lines.len() {
1000            return Ok(None);
1001        }
1002        let next_line = self.lines[self.pos + 1];
1003        if !is_table_separator(next_line) {
1004            return Ok(None);
1005        }
1006
1007        // Parse header row
1008        let header_cells = parse_table_row(line);
1009        self.advance(); // skip header
1010
1011        // Parse separator row for column alignment
1012        let sep_line = self.current_line();
1013        let alignments = parse_table_alignments(sep_line);
1014        self.advance(); // skip separator
1015
1016        let mut rows = Vec::new();
1017
1018        // Header row — parse cell attrs and apply column alignment
1019        let header_adf_cells: Vec<AdfNode> = header_cells
1020            .iter()
1021            .enumerate()
1022            .map(|(col_idx, cell)| {
1023                let (cell_text, cell_attrs) = extract_cell_attrs(cell);
1024                let mut para = AdfNode::paragraph(parse_inline(&cell_text));
1025                apply_column_alignment(&mut para, alignments.get(col_idx).copied().flatten());
1026                if let Some(attrs) = cell_attrs {
1027                    AdfNode::table_header_with_attrs(vec![para], attrs)
1028                } else {
1029                    AdfNode::table_header(vec![para])
1030                }
1031            })
1032            .collect();
1033        if header_adf_cells.is_empty() {
1034            warn!(
1035                "Pipe table header row at line {} has no cells",
1036                self.pos - 1
1037            );
1038        }
1039        rows.push(AdfNode::table_row(header_adf_cells));
1040
1041        // Body rows
1042        while !self.at_end() {
1043            let line = self.current_line();
1044            if !line.contains('|') || line.trim().is_empty() {
1045                break;
1046            }
1047
1048            let cells = parse_table_row(line);
1049            let adf_cells: Vec<AdfNode> = cells
1050                .iter()
1051                .enumerate()
1052                .map(|(col_idx, cell)| {
1053                    let (cell_text, cell_attrs) = extract_cell_attrs(cell);
1054                    let mut para = AdfNode::paragraph(parse_inline(&cell_text));
1055                    apply_column_alignment(&mut para, alignments.get(col_idx).copied().flatten());
1056                    if let Some(attrs) = cell_attrs {
1057                        AdfNode::table_cell_with_attrs(vec![para], attrs)
1058                    } else {
1059                        AdfNode::table_cell(vec![para])
1060                    }
1061                })
1062                .collect();
1063            if adf_cells.is_empty() {
1064                warn!("Pipe table body row at line {} has no cells", self.pos);
1065            }
1066            rows.push(AdfNode::table_row(adf_cells));
1067            self.advance();
1068        }
1069
1070        debug!("Parsed pipe table with {} rows", rows.len());
1071        let mut table = AdfNode::table(rows);
1072
1073        // Check for trailing {attrs} on the next line
1074        if !self.at_end() {
1075            let next = self.current_line().trim();
1076            if next.starts_with('{') {
1077                if let Some((_, attrs)) = parse_attrs(next, 0) {
1078                    let mut table_attrs = serde_json::json!({});
1079                    if let Some(layout) = attrs.get("layout") {
1080                        table_attrs["layout"] = serde_json::Value::String(layout.to_string());
1081                    }
1082                    if attrs.has_flag("numbered") {
1083                        table_attrs["isNumberColumnEnabled"] = serde_json::json!(true);
1084                    } else if attrs.get("numbered") == Some("false") {
1085                        table_attrs["isNumberColumnEnabled"] = serde_json::json!(false);
1086                    }
1087                    if let Some(tw) = attrs.get("width") {
1088                        if let Some(w) = parse_numeric_attr(tw) {
1089                            table_attrs["width"] = w;
1090                        }
1091                    }
1092                    if let Some(local_id) = attrs.get("localId") {
1093                        table_attrs["localId"] = serde_json::Value::String(local_id.to_string());
1094                    }
1095                    if table_attrs != serde_json::json!({}) {
1096                        table.attrs = Some(table_attrs);
1097                        self.advance(); // consume the attrs line
1098                    }
1099                }
1100            }
1101        }
1102
1103        Ok(Some(table))
1104    }
1105
1106    fn parse_paragraph(&mut self) -> Result<AdfNode> {
1107        let mut lines: Vec<&str> = Vec::new();
1108
1109        while !self.at_end() {
1110            let line = self.current_line();
1111            // Only break on block-level patterns if we already have paragraph
1112            // content. This prevents infinite loops when a line looks like a
1113            // block starter but doesn't actually match any block parser (e.g.,
1114            // "#NoSpace" which is not a valid heading).
1115            // Issue #494: A whitespace-only line that follows a hardBreak
1116            // marker (trailing backslash or two trailing spaces) is a
1117            // continuation, not a paragraph break.  Let it fall through to
1118            // the `is_hardbreak_cont` check below.
1119            if (line.trim().is_empty()
1120                && !lines
1121                    .last()
1122                    .is_some_and(|prev| has_trailing_hard_break(prev)))
1123                || is_code_fence_opener(line)
1124                || (is_horizontal_rule(line) && !lines.is_empty())
1125            {
1126                break;
1127            }
1128            // Strip 2-space indent from hardBreak continuation lines so
1129            // the content round-trips correctly (issue #455).
1130            let is_hardbreak_cont = !lines.is_empty()
1131                && line.starts_with("  ")
1132                && lines
1133                    .last()
1134                    .is_some_and(|prev| has_trailing_hard_break(prev));
1135            if is_hardbreak_cont {
1136                lines.push(&line[2..]);
1137                self.advance();
1138                continue;
1139            }
1140            if !lines.is_empty()
1141                && (line.starts_with('#') || line.starts_with('>') || is_list_start(line))
1142            {
1143                break;
1144            }
1145            // Break on trailing block attrs like {align=center}
1146            if !lines.is_empty() && is_block_attrs_line(line) {
1147                break;
1148            }
1149            lines.push(line);
1150            self.advance();
1151        }
1152
1153        let text = lines.join("\n");
1154        let inline_nodes = parse_inline(&text);
1155        Ok(AdfNode::paragraph(inline_nodes))
1156    }
1157}
1158
1159/// Builds ADF cell attributes from JFM directive attrs.
1160/// Maps: `bg` → `background`, `colspan` → number, `rowspan` → number, `colwidth` → array.
1161fn build_cell_attrs(attrs: &crate::atlassian::attrs::Attrs) -> serde_json::Value {
1162    let mut adf = serde_json::json!({});
1163    if let Some(bg) = attrs.get("bg") {
1164        adf["background"] = serde_json::Value::String(bg.to_string());
1165    }
1166    if let Some(colspan) = attrs.get("colspan") {
1167        if let Ok(n) = colspan.parse::<u32>() {
1168            adf["colspan"] = serde_json::json!(n);
1169        }
1170    }
1171    if let Some(rowspan) = attrs.get("rowspan") {
1172        if let Ok(n) = rowspan.parse::<u32>() {
1173            adf["rowspan"] = serde_json::json!(n);
1174        }
1175    }
1176    if let Some(colwidth) = attrs.get("colwidth") {
1177        let widths: Vec<serde_json::Value> = colwidth
1178            .split(',')
1179            .filter_map(|s| parse_numeric_attr(s.trim()))
1180            .collect();
1181        if !widths.is_empty() {
1182            adf["colwidth"] = serde_json::Value::Array(widths);
1183        }
1184    }
1185    if let Some(local_id) = attrs.get("localId") {
1186        adf["localId"] = serde_json::Value::String(local_id.to_string());
1187    }
1188    adf
1189}
1190
1191/// Extracts border marks from directive attributes (used by table cells and media nodes).
1192fn build_border_marks(attrs: &crate::atlassian::attrs::Attrs) -> Vec<AdfMark> {
1193    let mut marks = Vec::new();
1194    let border_color = attrs.get("border-color");
1195    let border_size = attrs.get("border-size");
1196    if border_color.is_some() || border_size.is_some() {
1197        let color = border_color.unwrap_or("#000000");
1198        let size = border_size.and_then(|s| s.parse::<u32>().ok()).unwrap_or(1);
1199        marks.push(AdfMark::border(color, size));
1200    }
1201    marks
1202}
1203
1204/// Converts an ISO 8601 date string (e.g., "2026-04-15") to epoch milliseconds string.
1205/// If the input is already numeric (epoch ms), returns it unchanged.
1206fn iso_date_to_epoch_ms(date_str: &str) -> String {
1207    // If it's already a numeric timestamp, pass through
1208    if date_str.chars().all(|c| c.is_ascii_digit()) {
1209        return date_str.to_string();
1210    }
1211    if let Ok(date) = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
1212        let epoch_ms = date
1213            .and_hms_opt(0, 0, 0)
1214            .map_or(0, |dt| dt.and_utc().timestamp_millis());
1215        epoch_ms.to_string()
1216    } else {
1217        // Fallback: pass through as-is
1218        date_str.to_string()
1219    }
1220}
1221
1222/// Converts an epoch milliseconds string to an ISO 8601 date string.
1223/// If the input looks like an ISO date already, returns it unchanged.
1224fn epoch_ms_to_iso_date(timestamp: &str) -> String {
1225    // If it looks like an ISO date already, pass through
1226    if timestamp.contains('-') {
1227        return timestamp.to_string();
1228    }
1229    if let Ok(ms) = timestamp.parse::<i64>() {
1230        let secs = ms / 1000;
1231        if let Some(dt) = chrono::DateTime::from_timestamp(secs, 0) {
1232            return dt.format("%Y-%m-%d").to_string();
1233        }
1234    }
1235    // Fallback: pass through
1236    timestamp.to_string()
1237}
1238
1239/// Checks if a line is a standalone block-level attrs line like `{align=center}`.
1240fn is_block_attrs_line(line: &str) -> bool {
1241    let trimmed = line.trim();
1242    if !trimmed.starts_with('{') || !trimmed.ends_with('}') {
1243        return false;
1244    }
1245    if let Some((_, attrs)) = parse_attrs(trimmed, 0) {
1246        // Only consider it a block attrs line if it has recognized block attrs
1247        attrs.get("align").is_some()
1248            || attrs.get("indent").is_some()
1249            || attrs.get("breakout").is_some()
1250            || attrs.get("breakoutWidth").is_some()
1251            || attrs.get("localId").is_some()
1252    } else {
1253        false
1254    }
1255}
1256
1257/// Parses decision items from the inner content of a `:::decisions` container.
1258/// Each item starts with `- <> ` prefix.
1259fn parse_decision_items(text: &str) -> Vec<AdfNode> {
1260    let mut items = Vec::new();
1261    for line in text.lines() {
1262        let trimmed = line.trim();
1263        if let Some(rest) = trimmed.strip_prefix("- <> ") {
1264            let inline_nodes = parse_inline(rest);
1265            items.push(AdfNode::decision_item("DECIDED", inline_nodes));
1266        }
1267    }
1268    items
1269}
1270
1271/// Tries to parse a task list marker `[ ]`, `[x]`, or `[X]` at the start of text.
1272/// Returns `("TODO"|"DONE", remaining_text)` on success.
1273///
1274/// The marker must be followed by a space or the end of the text, so that
1275/// empty task items (`- [ ]` with no body) are still recognised as tasks
1276/// rather than being treated as bullet items containing literal `[ ]` text
1277/// (issue #548).
1278fn try_parse_task_marker(text: &str) -> Option<(&str, &str)> {
1279    if let Some(rest) = strip_task_checkbox(text, "[ ]") {
1280        Some(("TODO", rest))
1281    } else if let Some(rest) =
1282        strip_task_checkbox(text, "[x]").or_else(|| strip_task_checkbox(text, "[X]"))
1283    {
1284        Some(("DONE", rest))
1285    } else {
1286        None
1287    }
1288}
1289
1290/// Strips a checkbox prefix from `text` if the character after the checkbox
1291/// is a space or the text ends there.  Returns the remaining text (with the
1292/// separating space consumed, if any).
1293fn strip_task_checkbox<'a>(text: &'a str, checkbox: &str) -> Option<&'a str> {
1294    let rest = text.strip_prefix(checkbox)?;
1295    if rest.is_empty() {
1296        Some(rest)
1297    } else {
1298        rest.strip_prefix(' ')
1299    }
1300}
1301
1302/// Returns true if `s` begins with a sequence the bullet-list parser would
1303/// interpret as a task checkbox marker (`[ ]`, `[x]`, or `[X]` followed by
1304/// a space, newline, or end-of-input).
1305///
1306/// Used by the `bulletList` renderer to decide whether to escape the leading
1307/// `[` of an item whose literal text starts with a checkbox-shaped prefix
1308/// (issue #548).
1309fn starts_with_task_marker(s: &str) -> bool {
1310    let after = if let Some(rest) = s.strip_prefix("[ ]") {
1311        rest
1312    } else if let Some(rest) = s.strip_prefix("[x]").or_else(|| s.strip_prefix("[X]")) {
1313        rest
1314    } else {
1315        return false;
1316    };
1317    after.is_empty() || after.starts_with(' ') || after.starts_with('\n')
1318}
1319
1320/// Parses an ordered list marker like "1. " and returns (number, rest_of_line).
1321fn parse_ordered_list_marker(line: &str) -> Option<(u32, &str)> {
1322    let digit_end = line.find(|c: char| !c.is_ascii_digit())?;
1323    if digit_end == 0 {
1324        return None;
1325    }
1326    let rest = &line[digit_end..];
1327    let after_marker = rest.strip_prefix(". ")?;
1328    let num: u32 = line[..digit_end].parse().ok()?;
1329    Some((num, after_marker))
1330}
1331
1332/// Returns true if a line ends with a hardBreak marker
1333/// (trailing backslash or two trailing spaces).
1334fn has_trailing_hard_break(line: &str) -> bool {
1335    line.ends_with('\\') || line.ends_with("  ")
1336}
1337
1338/// Returns true if the already-trimmed continuation line starts with a
1339/// block-level marker that must not be swallowed as a paragraph continuation
1340/// in `collect_hardbreak_continuations`.
1341///
1342/// Covers mediaSingle (`![` — issue #490), fenced code blocks (```` ``` ````
1343/// — issue #552), and container directives (`:::`).  The caller is expected
1344/// to have already stripped leading whitespace.
1345fn is_block_level_continuation_marker(trimmed: &str) -> bool {
1346    trimmed.starts_with("![") || trimmed.starts_with("```") || trimmed.starts_with(":::")
1347}
1348
1349/// Checks if a line starts a list item.
1350fn is_list_start(line: &str) -> bool {
1351    let trimmed = line.trim_start();
1352    trimmed.starts_with("- ")
1353        || trimmed.starts_with("* ")
1354        || trimmed.starts_with("+ ")
1355        || parse_ordered_list_marker(trimmed).is_some()
1356}
1357
1358/// Escapes asterisk and underscore sequences in text that would otherwise be
1359/// parsed as CommonMark emphasis (`*…*`, `_…_`) or strong emphasis (`**…**`,
1360/// `__…__`).
1361///
1362/// Asterisks are always escaped (they're rare in prose and the JFM parser
1363/// will gladly match them across node boundaries). Underscores are escaped
1364/// per the intraword rule: a `_` is left as-is only when it's clearly
1365/// intraword *within this text node* (alphanumeric on both sides). At the
1366/// node boundary or next to non-alphanumeric characters we escape, since
1367/// adjacent text nodes can supply the other side of an emphasis pair (issue
1368/// #554: `"_ "` followed by colored `"_Action…"` produced `_ :span[_…` which
1369/// parsed as italic and destroyed the span directive).
1370fn escape_emphasis_markers(text: &str) -> String {
1371    escape_emphasis_with(text, false)
1372}
1373
1374/// Variant of [`escape_emphasis_markers`] that escapes ALL underscores (even
1375/// intraword), not just boundary ones.
1376///
1377/// Must be used whenever the rendered markdown wraps this text in an `_..._`
1378/// em delimiter, because an unescaped `_` anywhere in the content would
1379/// otherwise close the delimiter prematurely (e.g. `_foo_bar_baz_` parses as
1380/// em("foo") + "bar" + em("baz"), not em("foo_bar_baz")).
1381fn escape_emphasis_markers_with_underscore(text: &str) -> String {
1382    escape_emphasis_with(text, true)
1383}
1384
1385/// Internal: escapes `*` always, and escapes `_` per the CommonMark intraword
1386/// rule by default — boundary or punctuation-adjacent runs are escaped, fully
1387/// intraword runs are left as-is.  When `escape_underscore_always` is true,
1388/// every `_` is escaped regardless (used when the surrounding context is an
1389/// `_..._` em delimiter that any inner `_` would close prematurely).
1390fn escape_emphasis_with(text: &str, escape_underscore_always: bool) -> String {
1391    let chars: Vec<char> = text.chars().collect();
1392    let mut out = String::with_capacity(text.len());
1393    let mut idx = 0;
1394    while idx < chars.len() {
1395        let ch = chars[idx];
1396        if ch == '*' {
1397            out.push('\\');
1398            out.push(ch);
1399            idx += 1;
1400        } else if ch == '_' {
1401            // Find the extent of this run of underscores. CommonMark treats
1402            // consecutive `_` as a single delimiter run, so the intraword
1403            // check applies to the whole run, not individual characters.
1404            let run_start = idx;
1405            let mut run_end = idx;
1406            while run_end < chars.len() && chars[run_end] == '_' {
1407                run_end += 1;
1408            }
1409            let escape_run = if escape_underscore_always {
1410                true
1411            } else {
1412                let before_alnum = run_start > 0 && chars[run_start - 1].is_alphanumeric();
1413                let after_alnum = chars.get(run_end).is_some_and(|c| c.is_alphanumeric());
1414                !(before_alnum && after_alnum)
1415            };
1416            for _ in run_start..run_end {
1417                if escape_run {
1418                    out.push('\\');
1419                }
1420                out.push('_');
1421            }
1422            idx = run_end;
1423        } else {
1424            out.push(ch);
1425            idx += 1;
1426        }
1427    }
1428    out
1429}
1430
1431/// Escapes backtick characters in text that would otherwise be parsed as
1432/// inline code spans (`` `…` ``).
1433///
1434/// Each backtick is prefixed with a backslash so that the JFM parser treats
1435/// it as a literal character rather than an inline-code delimiter.
1436fn escape_backticks(text: &str) -> String {
1437    let mut out = String::with_capacity(text.len());
1438    for ch in text.chars() {
1439        if ch == '`' {
1440            out.push('\\');
1441        }
1442        out.push(ch);
1443    }
1444    out
1445}
1446
1447/// Chooses a backtick delimiter length and padding flag for rendering `text`
1448/// as a CommonMark inline code span.
1449///
1450/// Per CommonMark: the delimiter must be a run of backticks not equal in
1451/// length to any run inside the content, and if both ends of the content
1452/// would start/end with a space (or with a backtick), a single space of
1453/// padding is added so the span survives the spec's space-stripping rule.
1454fn inline_code_delimiter(text: &str) -> (usize, bool) {
1455    let mut max_run = 0usize;
1456    let mut current = 0usize;
1457    for ch in text.chars() {
1458        if ch == '`' {
1459            current += 1;
1460            if current > max_run {
1461                max_run = current;
1462            }
1463        } else {
1464            current = 0;
1465        }
1466    }
1467    let n = max_run + 1;
1468    let starts_bt = text.starts_with('`');
1469    let ends_bt = text.ends_with('`');
1470    let starts_sp = text.starts_with(' ');
1471    let ends_sp = text.ends_with(' ');
1472    let all_sp = !text.is_empty() && text.chars().all(|c| c == ' ');
1473    let needs_pad = starts_bt || ends_bt || (starts_sp && ends_sp && !all_sp);
1474    (n, needs_pad)
1475}
1476
1477/// Appends `text` to `output` wrapped in a CommonMark inline code span whose
1478/// delimiter length allows any embedded backticks to round-trip unambiguously.
1479fn render_inline_code(text: &str, output: &mut String) {
1480    let (n, pad) = inline_code_delimiter(text);
1481    for _ in 0..n {
1482        output.push('`');
1483    }
1484    if pad {
1485        output.push(' ');
1486    }
1487    output.push_str(text);
1488    if pad {
1489        output.push(' ');
1490    }
1491    for _ in 0..n {
1492        output.push('`');
1493    }
1494}
1495
1496/// Escapes pipe characters in text that appears inside a GFM pipe table cell.
1497///
1498/// Without this, a literal `|` in cell content (including inside inline code
1499/// spans) is interpreted as a column separator on round-trip, splitting the
1500/// cell and corrupting its content (see issue #579).  Each `|` is prefixed
1501/// with a backslash so the table-row parser treats it as literal.
1502fn escape_pipes_in_cell(text: &str) -> String {
1503    let mut out = String::with_capacity(text.len());
1504    for ch in text.chars() {
1505        if ch == '|' {
1506            out.push('\\');
1507        }
1508        out.push(ch);
1509    }
1510    out
1511}
1512
1513/// Escapes square brackets (`[` and `]`) in text that will appear inside a
1514/// markdown link's `[…]` delimiters.  Without this, a text node containing a
1515/// literal `[` or `]` can create ambiguous markdown link syntax on round-trip
1516/// (see issue #493).
1517fn escape_link_brackets(text: &str) -> String {
1518    let mut out = String::with_capacity(text.len());
1519    for ch in text.chars() {
1520        if ch == '[' || ch == ']' {
1521            out.push('\\');
1522        }
1523        out.push(ch);
1524    }
1525    out
1526}
1527
1528/// Escapes bare URLs (`http://` and `https://`) in plain text so they are not
1529/// parsed as `inlineCard` nodes during round-trip.  The leading `h` is
1530/// backslash-escaped, which is enough to prevent the auto-link detector from
1531/// matching the URL while the existing backslash-escape handler restores it on
1532/// re-parse.
1533fn escape_bare_urls(text: &str) -> String {
1534    let mut result = String::with_capacity(text.len());
1535    for (i, ch) in text.char_indices() {
1536        if ch == 'h' {
1537            let rest = &text[i..];
1538            if rest.starts_with("http://") || rest.starts_with("https://") {
1539                result.push('\\');
1540            }
1541        }
1542        result.push(ch);
1543    }
1544    result
1545}
1546
1547/// Returns `true` if the string can be embedded in a `:card[...]` (or similar
1548/// bracketed inline directive) without breaking the depth-based bracket matcher
1549/// used by [`try_parse_inline_directive`].
1550///
1551/// The parser scans the content enclosed in `[...]` treating `[` as +1 and `]`
1552/// as −1 on depth, closing when depth returns to zero.  A value is safe if
1553/// every prefix has `count('[') >= count(']') − 1` (i.e., the running depth
1554/// never dips below zero before the end) and it contains no newline.
1555fn url_safe_in_bracket_content(s: &str) -> bool {
1556    if s.contains('\n') {
1557        return false;
1558    }
1559    let mut depth: i32 = 1;
1560    for ch in s.chars() {
1561        match ch {
1562            '[' => depth += 1,
1563            ']' => {
1564                depth -= 1;
1565                if depth == 0 {
1566                    return false;
1567                }
1568            }
1569            _ => {}
1570        }
1571    }
1572    true
1573}
1574
1575/// Escapes emoji shortcode patterns (`:name:`) in plain text so they are not
1576/// parsed as emoji nodes during round-trip.  Only the leading colon is
1577/// backslash-escaped, which is enough to prevent the parser from matching the
1578/// pattern while the existing backslash-escape handler restores it on re-parse.
1579///
1580/// The character class for the name segment must match `try_parse_emoji_shortcode`
1581/// exactly (Unicode `is_alphanumeric` plus `_`, `+`, `-`).  An ASCII-only escape
1582/// would leave Unicode patterns like `:Café:` or `:ZBC::Acme::配置:` un-escaped
1583/// while still being detected as emoji on re-parse, splitting the text node
1584/// (issue #552).
1585fn escape_emoji_shortcodes(text: &str) -> String {
1586    let mut result = String::with_capacity(text.len());
1587
1588    for (i, ch) in text.char_indices() {
1589        if ch == ':' {
1590            // Check if this is a `:name:` pattern where name matches the
1591            // same character class accepted by `try_parse_emoji_shortcode`.
1592            let after = i + 1;
1593            if after < text.len() {
1594                let name_end = text[after..]
1595                    .find(|c: char| !c.is_alphanumeric() && c != '_' && c != '+' && c != '-')
1596                    .map_or(text[after..].len(), |pos| pos);
1597                if name_end > 0
1598                    && after + name_end < text.len()
1599                    && text.as_bytes()[after + name_end] == b':'
1600                {
1601                    // Found `:name:` pattern — escape the leading colon
1602                    result.push('\\');
1603                }
1604            }
1605        }
1606        result.push(ch);
1607    }
1608
1609    result
1610}
1611
1612/// Escapes a leading list-marker pattern on a line so it is not
1613/// re-parsed as a new list item.  `"2. text"` → `"2\. text"`,
1614/// `"- text"` → `"\- text"`.
1615fn escape_list_marker(line: &str) -> String {
1616    if let Some(dot_pos) = line.find(". ") {
1617        if parse_ordered_list_marker(line).is_some() {
1618            let mut s = String::with_capacity(line.len() + 1);
1619            s.push_str(&line[..dot_pos]);
1620            s.push('\\');
1621            s.push_str(&line[dot_pos..]);
1622            return s;
1623        }
1624    }
1625    for prefix in &["- ", "* ", "+ "] {
1626        if line.starts_with(prefix) {
1627            let mut s = String::with_capacity(line.len() + 1);
1628            s.push('\\');
1629            s.push_str(line);
1630            return s;
1631        }
1632    }
1633    line.to_string()
1634}
1635
1636/// Checks if a line is a valid fenced code block opener.
1637///
1638/// Per CommonMark: the opener is a sequence of three or more backticks
1639/// followed by an info string that must not contain any backtick
1640/// character, otherwise some inline code spans would be misinterpreted
1641/// as the beginning of a fenced code block.
1642fn is_code_fence_opener(line: &str) -> bool {
1643    if !line.starts_with("```") {
1644        return false;
1645    }
1646    !line[3..].contains('`')
1647}
1648
1649/// Checks if a line is a horizontal rule.
1650fn is_horizontal_rule(line: &str) -> bool {
1651    let trimmed = line.trim();
1652    trimmed.len() >= 3
1653        && ((trimmed.starts_with("---") && trimmed.chars().all(|c| c == '-'))
1654            || (trimmed.starts_with("***") && trimmed.chars().all(|c| c == '*'))
1655            || (trimmed.starts_with("___") && trimmed.chars().all(|c| c == '_')))
1656}
1657
1658/// Checks if a line is a GFM table separator (e.g., "|---|---|").
1659fn is_table_separator(line: &str) -> bool {
1660    let trimmed = line.trim();
1661    trimmed.contains('|')
1662        && trimmed
1663            .chars()
1664            .all(|c| c == '|' || c == '-' || c == ':' || c == ' ')
1665}
1666
1667/// Parses a GFM table row into cell contents.
1668///
1669/// Splits on unescaped `|` characters; a preceding backslash (`\|`) is
1670/// interpreted as a literal pipe and unescaped in the emitted cell content
1671/// (see issue #579).  This allows code spans and other inline content that
1672/// contain literal `|` to survive round-trip through a pipe table.
1673fn parse_table_row(line: &str) -> Vec<String> {
1674    let trimmed = line.trim();
1675    let trimmed = trimmed.strip_prefix('|').unwrap_or(trimmed);
1676    let trimmed = trimmed.strip_suffix('|').unwrap_or(trimmed);
1677
1678    let mut cells: Vec<String> = Vec::new();
1679    let mut current = String::new();
1680    let mut chars = trimmed.chars().peekable();
1681    while let Some(ch) = chars.next() {
1682        if ch == '\\' && chars.peek() == Some(&'|') {
1683            current.push('|');
1684            chars.next();
1685        } else if ch == '|' {
1686            cells.push(std::mem::take(&mut current));
1687        } else {
1688            current.push(ch);
1689        }
1690    }
1691    cells.push(current);
1692
1693    cells
1694        .iter()
1695        .map(|s| {
1696            // Strip exactly one leading and one trailing space (pipe table padding).
1697            // Preserve any additional whitespace as significant content.
1698            let stripped = s.strip_prefix(' ').unwrap_or(s.as_str());
1699            let stripped = stripped.strip_suffix(' ').unwrap_or(stripped);
1700            stripped.to_string()
1701        })
1702        .collect()
1703}
1704
1705/// Parses column alignments from a GFM table separator row.
1706/// Returns a vec of `Option<&str>` where `Some("center")` or `Some("end")` indicate alignment.
1707fn parse_table_alignments(separator_line: &str) -> Vec<Option<&'static str>> {
1708    let trimmed = separator_line.trim();
1709    let trimmed = trimmed.strip_prefix('|').unwrap_or(trimmed);
1710    let trimmed = trimmed.strip_suffix('|').unwrap_or(trimmed);
1711
1712    trimmed
1713        .split('|')
1714        .map(|cell| {
1715            let cell = cell.trim();
1716            let starts_colon = cell.starts_with(':');
1717            let ends_colon = cell.ends_with(':');
1718            match (starts_colon, ends_colon) {
1719                (true, true) => Some("center"),
1720                (false, true) => Some("end"),
1721                _ => None, // left/default
1722            }
1723        })
1724        .collect()
1725}
1726
1727/// Applies an alignment mark to a paragraph node if alignment is specified.
1728fn apply_column_alignment(para: &mut AdfNode, alignment: Option<&str>) {
1729    if let Some(align) = alignment {
1730        para.marks = Some(vec![AdfMark::alignment(align)]);
1731    }
1732}
1733
1734/// Extracts `{attrs}` prefix from a pipe table cell text.
1735/// Returns `(remaining_text, Option<adf_attrs_json>)`.
1736fn extract_cell_attrs(cell_text: &str) -> (String, Option<serde_json::Value>) {
1737    let trimmed = cell_text.trim_start();
1738    if !trimmed.starts_with('{') {
1739        return (cell_text.to_string(), None);
1740    }
1741    if let Some((end_pos, attrs)) = parse_attrs(trimmed, 0) {
1742        let remaining = trimmed[end_pos..].trim_start().to_string();
1743        let adf_attrs = build_cell_attrs(&attrs);
1744        (remaining, Some(adf_attrs))
1745    } else {
1746        (cell_text.to_string(), None)
1747    }
1748}
1749
1750/// Tries to parse a line as a block-level image and return a mediaSingle ADF node.
1751/// Used by both `try_image` (top-level blocks) and list item parsing.
1752fn try_parse_media_single_from_line(line: &str) -> Option<AdfNode> {
1753    let line = line.trim();
1754    if !line.starts_with("![") {
1755        return None;
1756    }
1757
1758    let (alt, url) = parse_image_syntax(line)?;
1759    let alt_opt = if alt.is_empty() { None } else { Some(alt) };
1760
1761    let paren_open = line.find("](")? + 1; // index of '('
1762    let img_end = find_closing_paren(line, paren_open)? + 1;
1763    let after_img = line[img_end..].trim_start();
1764
1765    if after_img.starts_with('{') {
1766        if let Some((_, attrs)) = parse_attrs(after_img, 0) {
1767            // Confluence file attachment — reconstruct type:file media node
1768            if attrs.get("type") == Some("file") || attrs.get("id").is_some() {
1769                let mut media_attrs = serde_json::json!({"type": "file"});
1770                if let Some(id) = attrs.get("id") {
1771                    media_attrs["id"] = serde_json::Value::String(id.to_string());
1772                }
1773                if let Some(collection) = attrs.get("collection") {
1774                    media_attrs["collection"] = serde_json::Value::String(collection.to_string());
1775                }
1776                if let Some(occurrence_key) = attrs.get("occurrenceKey") {
1777                    media_attrs["occurrenceKey"] =
1778                        serde_json::Value::String(occurrence_key.to_string());
1779                }
1780                if let Some(height) = attrs.get("height") {
1781                    if let Some(h) = parse_numeric_attr(height) {
1782                        media_attrs["height"] = h;
1783                    }
1784                }
1785                if let Some(width) = attrs.get("width") {
1786                    if let Some(w) = parse_numeric_attr(width) {
1787                        media_attrs["width"] = w;
1788                    }
1789                }
1790                if let Some(alt_text) = alt_opt {
1791                    media_attrs["alt"] = serde_json::Value::String(alt_text.to_string());
1792                }
1793                if let Some(local_id) = attrs.get("localId") {
1794                    media_attrs["localId"] = serde_json::Value::String(local_id.to_string());
1795                }
1796                let mut ms_attrs = serde_json::json!({"layout": "center"});
1797                if let Some(layout) = attrs.get("layout") {
1798                    ms_attrs["layout"] = serde_json::Value::String(layout.to_string());
1799                }
1800                if let Some(ms_width) = attrs.get("mediaWidth") {
1801                    if let Some(w) = parse_numeric_attr(ms_width) {
1802                        ms_attrs["width"] = w;
1803                    }
1804                }
1805                if let Some(wt) = attrs.get("widthType") {
1806                    ms_attrs["widthType"] = serde_json::Value::String(wt.to_string());
1807                }
1808                if let Some(mode) = attrs.get("mode") {
1809                    ms_attrs["mode"] = serde_json::Value::String(mode.to_string());
1810                }
1811                let border_marks = build_border_marks(&attrs);
1812                let media_marks = if border_marks.is_empty() {
1813                    None
1814                } else {
1815                    Some(border_marks)
1816                };
1817                return Some(AdfNode {
1818                    node_type: "mediaSingle".to_string(),
1819                    attrs: Some(ms_attrs),
1820                    content: Some(vec![AdfNode {
1821                        node_type: "media".to_string(),
1822                        attrs: Some(media_attrs),
1823                        content: None,
1824                        text: None,
1825                        marks: media_marks,
1826                        local_id: None,
1827                        parameters: None,
1828                    }]),
1829                    text: None,
1830                    marks: None,
1831                    local_id: None,
1832                    parameters: None,
1833                });
1834            }
1835
1836            // External image — apply layout/width/widthType to mediaSingle attrs
1837            let mut node = AdfNode::media_single(url, alt_opt);
1838            if let Some(ref mut node_attrs) = node.attrs {
1839                if let Some(layout) = attrs.get("layout") {
1840                    node_attrs["layout"] = serde_json::Value::String(layout.to_string());
1841                }
1842                if let Some(width) = attrs.get("width") {
1843                    if let Some(w) = parse_numeric_attr(width) {
1844                        node_attrs["width"] = w;
1845                    }
1846                }
1847                if let Some(wt) = attrs.get("widthType") {
1848                    node_attrs["widthType"] = serde_json::Value::String(wt.to_string());
1849                }
1850                if let Some(mode) = attrs.get("mode") {
1851                    node_attrs["mode"] = serde_json::Value::String(mode.to_string());
1852                }
1853            }
1854            if let Some(ref mut content) = node.content {
1855                if let Some(media) = content.first_mut() {
1856                    if let Some(local_id) = attrs.get("localId") {
1857                        if let Some(ref mut media_attrs) = media.attrs {
1858                            media_attrs["localId"] =
1859                                serde_json::Value::String(local_id.to_string());
1860                        }
1861                    }
1862                    let border_marks = build_border_marks(&attrs);
1863                    if !border_marks.is_empty() {
1864                        media.marks = Some(border_marks);
1865                    }
1866                }
1867            }
1868            return Some(node);
1869        }
1870    }
1871
1872    Some(AdfNode::media_single(url, alt_opt))
1873}
1874
1875/// Parses `![alt](url)` image syntax.
1876fn parse_image_syntax(line: &str) -> Option<(&str, &str)> {
1877    let line = line.trim();
1878    if !line.starts_with("![") {
1879        return None;
1880    }
1881
1882    let alt_end = line.find("](")?;
1883    let alt = &line[2..alt_end];
1884    let paren_start = alt_end + 1; // index of the '('
1885    let url_end = find_closing_paren(line, paren_start)?;
1886    let url = &line[paren_start + 1..url_end];
1887
1888    Some((alt, url))
1889}
1890
1891// ── Inline Parsing ──────────────────────────────────────────────────
1892
1893/// Parses inline markdown content into ADF inline nodes.
1894///
1895/// Detects bare URLs (e.g., `https://example.com`) and promotes them to
1896/// `inlineCard` nodes. Call this at the top level (paragraph, heading, cell,
1897/// list item) where a bare URL represents a smart link.
1898fn parse_inline(text: &str) -> Vec<AdfNode> {
1899    parse_inline_impl(text, true)
1900}
1901
1902/// Parses inline markdown content without promoting bare URLs to `inlineCard`.
1903///
1904/// Used when recursing into mark-wrapping constructs such as emphasis, strike,
1905/// bracketed spans, or links.  In those contexts, the enclosing syntax already
1906/// declares the semantic role of the content — a URL inside `[url]{underline}`
1907/// or `**url**` is the user's text, not a smart link (issue #553).
1908fn parse_inline_no_auto_cards(text: &str) -> Vec<AdfNode> {
1909    parse_inline_impl(text, false)
1910}
1911
1912/// Implementation backing [`parse_inline`] and [`parse_inline_no_auto_cards`].
1913///
1914/// When `auto_inline_card` is `false`, bare `http://`/`https://` URLs are
1915/// treated as plain text instead of being promoted to `inlineCard` nodes.
1916fn parse_inline_impl(text: &str, auto_inline_card: bool) -> Vec<AdfNode> {
1917    let mut nodes = Vec::new();
1918    let mut chars = text.char_indices().peekable();
1919    let mut plain_start = 0;
1920
1921    while let Some(&(i, ch)) = chars.peek() {
1922        match ch {
1923            '*' | '_' => {
1924                if let Some((end, content, is_bold)) = try_parse_emphasis(text, i) {
1925                    flush_plain(text, plain_start, i, &mut nodes);
1926                    let mark = if is_bold {
1927                        AdfMark::strong()
1928                    } else {
1929                        AdfMark::em()
1930                    };
1931                    let inner = parse_inline_no_auto_cards(content);
1932                    for mut node in inner {
1933                        prepend_mark(&mut node, mark.clone());
1934                        nodes.push(node);
1935                    }
1936                    // Advance past the consumed characters
1937                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
1938                        chars.next();
1939                    }
1940                    plain_start = end;
1941                    continue;
1942                }
1943                // For underscores, skip the entire delimiter run so that
1944                // individual `_` chars within a `__` or `___` run are not
1945                // re-tried as separate emphasis openers (CommonMark treats
1946                // consecutive underscores as a single delimiter run).
1947                if ch == '_' {
1948                    while chars.peek().is_some_and(|&(_, c)| c == '_') {
1949                        chars.next();
1950                    }
1951                } else {
1952                    chars.next();
1953                }
1954            }
1955            '~' => {
1956                if let Some((end, content)) = try_parse_strikethrough(text, i) {
1957                    flush_plain(text, plain_start, i, &mut nodes);
1958                    let inner = parse_inline_no_auto_cards(content);
1959                    for mut node in inner {
1960                        prepend_mark(&mut node, AdfMark::strike());
1961                        nodes.push(node);
1962                    }
1963                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
1964                        chars.next();
1965                    }
1966                    plain_start = end;
1967                    continue;
1968                }
1969                chars.next();
1970            }
1971            '`' => {
1972                if let Some((end, content)) = try_parse_inline_code(text, i) {
1973                    flush_plain(text, plain_start, i, &mut nodes);
1974                    nodes.push(AdfNode::text_with_marks(content, vec![AdfMark::code()]));
1975                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
1976                        chars.next();
1977                    }
1978                    plain_start = end;
1979                    continue;
1980                }
1981                // No code span starting here; skip past the entire backtick
1982                // run so a longer opening run isn't retried as a shorter one.
1983                while chars.peek().is_some_and(|&(_, c)| c == '`') {
1984                    chars.next();
1985                }
1986            }
1987            '[' => {
1988                if let Some((end, link_text, href)) = try_parse_link(text, i) {
1989                    flush_plain(text, plain_start, i, &mut nodes);
1990                    if link_text.starts_with("http://") || link_text.starts_with("https://") {
1991                        // URL-as-link-text: emit as text with link mark,
1992                        // not via parse_inline which would produce an inlineCard.
1993                        // Covers both exact matches and trailing-slash mismatches
1994                        // (issue #523).
1995                        nodes.push(AdfNode::text_with_marks(
1996                            link_text,
1997                            vec![AdfMark::link(href)],
1998                        ));
1999                    } else {
2000                        let inner = parse_inline_no_auto_cards(link_text);
2001                        for mut node in inner {
2002                            prepend_mark(&mut node, AdfMark::link(href));
2003                            nodes.push(node);
2004                        }
2005                    }
2006                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2007                        chars.next();
2008                    }
2009                    plain_start = end;
2010                    continue;
2011                }
2012                // Try bracketed span with attributes: [text]{underline}
2013                if let Some((end, span_nodes)) = try_parse_bracketed_span(text, i) {
2014                    flush_plain(text, plain_start, i, &mut nodes);
2015                    nodes.extend(span_nodes);
2016                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2017                        chars.next();
2018                    }
2019                    plain_start = end;
2020                    continue;
2021                }
2022                chars.next();
2023            }
2024            ':' => {
2025                // Try generic inline directive (:card[url], :status[text]{attrs}, etc.)
2026                if let Some(node) = try_dispatch_inline_directive(text, i) {
2027                    flush_plain(text, plain_start, i, &mut nodes);
2028                    let end = node.1;
2029                    nodes.push(node.0);
2030                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2031                        chars.next();
2032                    }
2033                    plain_start = end;
2034                    continue;
2035                }
2036                // Try emoji shortcode :name: with optional {attrs}
2037                if let Some((end, short_name)) = try_parse_emoji_shortcode(text, i) {
2038                    flush_plain(text, plain_start, i, &mut nodes);
2039                    let (final_end, emoji_node) = parse_emoji_with_attrs(text, end, short_name);
2040                    nodes.push(emoji_node);
2041                    while chars.peek().is_some_and(|&(idx, _)| idx < final_end) {
2042                        chars.next();
2043                    }
2044                    plain_start = final_end;
2045                    continue;
2046                }
2047                chars.next();
2048            }
2049            ' ' if text[i..].starts_with("  \n") => {
2050                // Trailing-space line break → hardBreak node.
2051                // Flush preceding text (without the trailing spaces).
2052                flush_plain(text, plain_start, i, &mut nodes);
2053                nodes.push(AdfNode::hard_break());
2054                // Skip past all spaces and the newline
2055                while chars.peek().is_some_and(|&(_, c)| c == ' ') {
2056                    chars.next();
2057                }
2058                // Skip the newline
2059                if chars.peek().is_some_and(|&(_, c)| c == '\n') {
2060                    chars.next();
2061                }
2062                plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2063            }
2064            '!' if text[i..].starts_with("![") => {
2065                // Inline image — skip the ! and let [ handle it next iteration
2066                // (Images at block level are handled by try_image; inline images
2067                // degrade to link text in ADF since inline media is complex)
2068                chars.next();
2069            }
2070            'h' if auto_inline_card
2071                && (text[i..].starts_with("http://") || text[i..].starts_with("https://")) =>
2072            {
2073                if let Some((end, url)) = try_parse_bare_url(text, i) {
2074                    flush_plain(text, plain_start, i, &mut nodes);
2075                    nodes.push(AdfNode::inline_card(url));
2076                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2077                        chars.next();
2078                    }
2079                    plain_start = end;
2080                    continue;
2081                }
2082                chars.next();
2083            }
2084            '\\' if text.as_bytes().get(i + 1) == Some(&b'n')
2085                && text.as_bytes().get(i + 2) != Some(&b'\n') =>
2086            {
2087                // Issue #454: `\n` (backslash + letter n) encodes a literal
2088                // newline inside a text node. Emit the newline as a separate
2089                // text node so merge_adjacent_text can reassemble it.
2090                flush_plain(text, plain_start, i, &mut nodes);
2091                nodes.push(AdfNode::text("\n"));
2092                chars.next(); // consume the '\'
2093                chars.next(); // consume the 'n'
2094                plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2095            }
2096            '\\' if i + 1 < text.len() && !text[i..].starts_with("\\\n") => {
2097                // Backslash escape: skip the backslash and treat the next
2098                // character as literal text (e.g. `\\` → `\`,
2099                // `2\. text` → `2. text`, `\*word\*` → `*word*` without
2100                // emphasis, `\:fire:` → `:fire:` without emoji parsing).
2101                flush_plain(text, plain_start, i, &mut nodes);
2102                chars.next(); // consume the backslash
2103                              // Set plain_start to the escaped character so it is included
2104                              // in the next plain-text run, then advance past it so it is
2105                              // not re-interpreted as a special character (e.g. `*`, `_`, `:`).
2106                plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2107                chars.next(); // consume the escaped character
2108            }
2109            '\\' if text[i..].starts_with("\\\n") => {
2110                // Backslash line break → hardBreak node.
2111                flush_plain(text, plain_start, i, &mut nodes);
2112                nodes.push(AdfNode::hard_break());
2113                chars.next(); // consume the '\'
2114                              // Skip the newline
2115                if chars.peek().is_some_and(|&(_, c)| c == '\n') {
2116                    chars.next();
2117                }
2118                plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2119            }
2120            '\\' if i + 1 == text.len() => {
2121                // Trailing backslash at end of paragraph text → hardBreak node.
2122                flush_plain(text, plain_start, i, &mut nodes);
2123                nodes.push(AdfNode::hard_break());
2124                chars.next(); // consume the '\'
2125                plain_start = text.len();
2126            }
2127            _ => {
2128                chars.next();
2129            }
2130        }
2131    }
2132
2133    // Flush remaining plain text
2134    if plain_start < text.len() {
2135        let remaining = &text[plain_start..];
2136        if !remaining.is_empty() {
2137            nodes.push(AdfNode::text(remaining));
2138        }
2139    }
2140
2141    // Merge adjacent unmarked text nodes that can arise from backslash
2142    // escape handling (e.g. `"2"` + `". text"` → `"2. text"`).
2143    merge_adjacent_text(&mut nodes);
2144
2145    nodes
2146}
2147
2148/// Merges consecutive unmarked text nodes in-place.
2149fn merge_adjacent_text(nodes: &mut Vec<AdfNode>) {
2150    let mut i = 0;
2151    while i + 1 < nodes.len() {
2152        if nodes[i].node_type == "text"
2153            && nodes[i + 1].node_type == "text"
2154            && nodes[i].marks.is_none()
2155            && nodes[i + 1].marks.is_none()
2156        {
2157            let next_text = nodes[i + 1].text.clone().unwrap_or_default();
2158            if let Some(ref mut t) = nodes[i].text {
2159                t.push_str(&next_text);
2160            }
2161            nodes.remove(i + 1);
2162        } else {
2163            i += 1;
2164        }
2165    }
2166}
2167
2168/// Flushes accumulated plain text as a text node.
2169fn flush_plain(text: &str, start: usize, end: usize, nodes: &mut Vec<AdfNode>) {
2170    if start < end {
2171        let plain = &text[start..end];
2172        if !plain.is_empty() {
2173            nodes.push(AdfNode::text(plain));
2174        }
2175    }
2176}
2177
2178/// Adds a mark to a node (creates marks vec if needed).
2179#[cfg(test)]
2180fn add_mark(node: &mut AdfNode, mark: AdfMark) {
2181    if let Some(ref mut marks) = node.marks {
2182        marks.push(mark);
2183    } else {
2184        node.marks = Some(vec![mark]);
2185    }
2186}
2187
2188/// Removes `code` marks from heading inline content, returning the count
2189/// removed.
2190///
2191/// ADF's `heading` content model forbids the `code` mark (a heading styles
2192/// its own text), but JFM/CommonMark parses inline-code spans inside
2193/// `#`-headings. Stripping here keeps the text as plain — a lossy but safe
2194/// direction, since Atlassian renders no inline-code styling on headings —
2195/// rather than building a document the validator only rejects at write time
2196/// (issue #1005). Recurses into `content` to be robust to future inline
2197/// containers; today heading inline content is flat text nodes.
2198fn strip_heading_code_marks(nodes: &mut [AdfNode]) -> usize {
2199    let mut removed = 0;
2200    for node in nodes.iter_mut() {
2201        if let Some(marks) = node.marks.as_mut() {
2202            let before = marks.len();
2203            marks.retain(|m| m.mark_type != "code");
2204            removed += before - marks.len();
2205            if marks.is_empty() {
2206                node.marks = None;
2207            }
2208        }
2209        if let Some(children) = node.content.as_mut() {
2210            removed += strip_heading_code_marks(children);
2211        }
2212    }
2213    removed
2214}
2215
2216/// Prepends a mark before existing marks to preserve outside-in ordering.
2217fn prepend_mark(node: &mut AdfNode, mark: AdfMark) {
2218    if let Some(ref mut marks) = node.marks {
2219        marks.insert(0, mark);
2220    } else {
2221        node.marks = Some(vec![mark]);
2222    }
2223}
2224
2225/// Returns `true` when an underscore delimiter run of `len` bytes starting at
2226/// byte position `delim_pos` in `text` is flanked by alphanumeric characters on
2227/// **both** sides — meaning it sits inside a word and must NOT open or close an
2228/// emphasis span per CommonMark.
2229fn is_intraword_underscore(text: &str, delim_pos: usize, len: usize) -> bool {
2230    let before = text[..delim_pos]
2231        .chars()
2232        .next_back()
2233        .is_some_and(char::is_alphanumeric);
2234    let after = text[delim_pos + len..]
2235        .chars()
2236        .next()
2237        .is_some_and(char::is_alphanumeric);
2238    before && after
2239}
2240
2241/// Finds the first occurrence of `needle` in `haystack`, skipping over
2242/// backslash-escaped characters (e.g. `\*` is not matched when searching
2243/// for `*`).
2244fn find_unescaped(haystack: &str, needle: &str) -> Option<usize> {
2245    let needle_bytes = needle.as_bytes();
2246    let hay_bytes = haystack.as_bytes();
2247    let mut i = 0;
2248    while i < hay_bytes.len() {
2249        if hay_bytes[i] == b'\\' {
2250            i += 2; // skip escaped character
2251            continue;
2252        }
2253        if hay_bytes[i..].starts_with(needle_bytes) {
2254            return Some(i);
2255        }
2256        i += 1;
2257    }
2258    None
2259}
2260
2261/// Finds the first occurrence of a single byte `ch` in `haystack`, skipping
2262/// over backslash-escaped characters.
2263fn find_unescaped_char(haystack: &str, ch: u8) -> Option<usize> {
2264    let hay_bytes = haystack.as_bytes();
2265    let mut i = 0;
2266    while i < hay_bytes.len() {
2267        if hay_bytes[i] == b'\\' {
2268            i += 2;
2269            continue;
2270        }
2271        if hay_bytes[i] == ch {
2272            return Some(i);
2273        }
2274        i += 1;
2275    }
2276    None
2277}
2278
2279/// Tries to parse ***bold+italic***, **bold**, *italic* (or underscore variants) starting at position `i`.
2280/// Returns (end_position, inner_content, is_bold).
2281///
2282/// The triple-delimiter case (`***` / `___`) is checked first so that `***text***` is parsed as
2283/// bold wrapping italic content, rather than having the `**` branch consume the wrong closing
2284/// delimiter and leave stray `*` characters in the text (see issue #401).
2285///
2286/// For underscore delimiters, intraword positions are rejected per CommonMark: a `_` flanked
2287/// by alphanumeric characters on both sides must not open or close emphasis (see issue #438).
2288fn try_parse_emphasis(text: &str, i: usize) -> Option<(usize, &str, bool)> {
2289    let rest = &text[i..];
2290
2291    // Bold+italic: *** or ___
2292    // Parse as bold wrapping italic: the inner content will be recursively parsed and pick up
2293    // the inner * / _ as an em mark.
2294    if rest.starts_with("***") || rest.starts_with("___") {
2295        let is_underscore = rest.starts_with("___");
2296        if is_underscore && is_intraword_underscore(text, i, 3) {
2297            return None;
2298        }
2299        let triple = &rest[..3];
2300        let after = &rest[3..];
2301        if let Some(close) = find_unescaped(after, triple) {
2302            if close > 0 {
2303                let close_pos = i + 3 + close;
2304                if is_underscore && is_intraword_underscore(text, close_pos, 3) {
2305                    return None;
2306                }
2307                // Return a slice that includes the inner italic delimiters from the
2308                // original text: for `***text***`, return `*text*`.  The recursive
2309                // parse_inline call will then pick up the inner `*…*` as an em mark.
2310                let content = &rest[2..=3 + close];
2311                let end = i + 3 + close + 3;
2312                return Some((end, content, true));
2313            }
2314        }
2315    }
2316
2317    // Bold: ** or __
2318    if rest.starts_with("**") || rest.starts_with("__") {
2319        let is_underscore = rest.starts_with("__");
2320        if is_underscore && is_intraword_underscore(text, i, 2) {
2321            return None;
2322        }
2323        let delimiter = &rest[..2];
2324        let after = &rest[2..];
2325        let close = find_unescaped(after, delimiter)?;
2326        if close == 0 {
2327            return None;
2328        }
2329        let close_pos = i + 2 + close;
2330        if is_underscore && is_intraword_underscore(text, close_pos, 2) {
2331            return None;
2332        }
2333        let content = &after[..close];
2334        let end = i + 2 + close + 2;
2335        return Some((end, content, true));
2336    }
2337
2338    // Italic: * or _
2339    if rest.starts_with('*') || rest.starts_with('_') {
2340        let delim_char = rest.as_bytes()[0];
2341        let is_underscore = delim_char == b'_';
2342        if is_underscore && is_intraword_underscore(text, i, 1) {
2343            return None;
2344        }
2345        let after = &rest[1..];
2346        let close = find_unescaped_char(after, delim_char)?;
2347        if close == 0 {
2348            return None;
2349        }
2350        let close_pos = i + 1 + close;
2351        if is_underscore && is_intraword_underscore(text, close_pos, 1) {
2352            return None;
2353        }
2354        let content = &after[..close];
2355        let end = i + 1 + close + 1;
2356        return Some((end, content, false));
2357    }
2358
2359    None
2360}
2361
2362/// Tries to parse ~~strikethrough~~ starting at position `i`.
2363fn try_parse_strikethrough(text: &str, i: usize) -> Option<(usize, &str)> {
2364    let rest = &text[i..];
2365    if !rest.starts_with("~~") {
2366        return None;
2367    }
2368    let after = &rest[2..];
2369    let close = after.find("~~")?;
2370    if close == 0 {
2371        return None;
2372    }
2373    let content = &after[..close];
2374    Some((i + 2 + close + 2, content))
2375}
2376
2377/// Tries to parse a CommonMark inline code span starting at position `i`.
2378///
2379/// Supports multi-backtick delimiters: the opening run of N backticks must
2380/// be matched by a closing run of exactly N backticks.  If both ends of the
2381/// enclosed content begin and end with a space and the content is not all
2382/// spaces, one space is stripped from each side per the CommonMark spec.
2383fn try_parse_inline_code(text: &str, i: usize) -> Option<(usize, &str)> {
2384    let rest = &text[i..];
2385    let bytes = rest.as_bytes();
2386    if bytes.is_empty() || bytes[0] != b'`' {
2387        return None;
2388    }
2389    let mut opening = 0usize;
2390    while opening < bytes.len() && bytes[opening] == b'`' {
2391        opening += 1;
2392    }
2393
2394    let mut j = opening;
2395    while j < bytes.len() {
2396        if bytes[j] == b'`' {
2397            let run_start = j;
2398            while j < bytes.len() && bytes[j] == b'`' {
2399                j += 1;
2400            }
2401            if j - run_start == opening {
2402                let content = &rest[opening..run_start];
2403                let content = strip_code_span_padding(content);
2404                return Some((i + j, content));
2405            }
2406        } else {
2407            j += 1;
2408        }
2409    }
2410    None
2411}
2412
2413/// Implements the CommonMark code-span space-stripping rule: if the content
2414/// both begins and ends with a space character and is not composed entirely
2415/// of spaces, one space character is removed from each side.
2416fn strip_code_span_padding(content: &str) -> &str {
2417    let bytes = content.as_bytes();
2418    if bytes.len() >= 2
2419        && bytes[0] == b' '
2420        && bytes[bytes.len() - 1] == b' '
2421        && content.bytes().any(|b| b != b' ')
2422    {
2423        &content[1..content.len() - 1]
2424    } else {
2425        content
2426    }
2427}
2428
2429/// Tries to parse a bracketed span `[text]{attrs}` starting at position `i`.
2430/// Used for `[text]{underline}` and similar constructs.
2431fn try_parse_bracketed_span(text: &str, i: usize) -> Option<(usize, Vec<AdfNode>)> {
2432    let rest = &text[i..];
2433    if !rest.starts_with('[') {
2434        return None;
2435    }
2436
2437    // Find the matching ] by counting bracket depth (supports nested brackets
2438    // such as [[text](url)]{underline} for underline-before-link ordering).
2439    // Backslash-escaped brackets are skipped (issue #493).
2440    let mut depth: usize = 0;
2441    let mut bracket_close = None;
2442    let bs_bytes = rest.as_bytes();
2443    for (j, ch) in rest.char_indices() {
2444        match ch {
2445            '\\' if j + 1 < bs_bytes.len()
2446                && (bs_bytes[j + 1] == b'[' || bs_bytes[j + 1] == b']') => {}
2447            '[' if j == 0 || bs_bytes[j - 1] != b'\\' => depth += 1,
2448            ']' if j == 0 || bs_bytes[j - 1] != b'\\' => {
2449                depth -= 1;
2450                if depth == 0 {
2451                    bracket_close = Some(j);
2452                    break;
2453                }
2454            }
2455            _ => {}
2456        }
2457    }
2458    let bracket_close = bracket_close?;
2459    // Make sure this isn't a link: next char after ] must be { not (
2460    let after_bracket = &rest[bracket_close + 1..];
2461    if !after_bracket.starts_with('{') {
2462        return None;
2463    }
2464
2465    let span_text = &rest[1..bracket_close];
2466    let attrs_start = i + bracket_close + 1;
2467    let (attrs_end, attrs) = parse_attrs(text, attrs_start)?;
2468
2469    let mut marks = Vec::new();
2470    if attrs.has_flag("underline") {
2471        marks.push(AdfMark::underline());
2472    }
2473    let ann_ids = attrs.get_all("annotation-id");
2474    let ann_types = attrs.get_all("annotation-type");
2475    for (idx, ann_id) in ann_ids.iter().enumerate() {
2476        let ann_type = ann_types.get(idx).copied().unwrap_or("inlineComment");
2477        marks.push(AdfMark::annotation(ann_id, ann_type));
2478    }
2479
2480    if marks.is_empty() {
2481        return None; // no recognized marks
2482    }
2483
2484    let inner = parse_inline_no_auto_cards(span_text);
2485    let result: Vec<AdfNode> = inner
2486        .into_iter()
2487        .map(|mut node| {
2488            // Prepend bracket marks before inner marks to preserve original
2489            // ADF mark ordering (e.g., [underline, strong] not [strong, underline]).
2490            let mut combined = marks.clone();
2491            if let Some(ref existing) = node.marks {
2492                combined.extend(existing.iter().cloned());
2493            }
2494            node.marks = Some(combined);
2495            node
2496        })
2497        .collect();
2498
2499    Some((attrs_end, result))
2500}
2501
2502/// Dispatches an inline directive to the appropriate ADF node constructor.
2503/// Returns `(AdfNode, end_pos)` on success.
2504fn try_dispatch_inline_directive(text: &str, pos: usize) -> Option<(AdfNode, usize)> {
2505    let d = try_parse_inline_directive(text, pos)?;
2506    let content = d.content.as_deref().unwrap_or("");
2507
2508    let node = match d.name.as_str() {
2509        "card" => {
2510            // Prefer the `url` attribute when present; fall back to the
2511            // bracketed content.  The attribute form is used when the URL
2512            // contains characters (such as `]` or `\n`) that would otherwise
2513            // break `:card[URL]` parsing.
2514            let url = d
2515                .attrs
2516                .as_ref()
2517                .and_then(|a| a.get("url"))
2518                .unwrap_or(content);
2519            let mut node = AdfNode::inline_card(url);
2520            pass_through_local_id(&d.attrs, &mut node);
2521            node
2522        }
2523        "status" => {
2524            let color = d
2525                .attrs
2526                .as_ref()
2527                .and_then(|a| a.get("color"))
2528                .unwrap_or("neutral");
2529            let mut node = AdfNode::status(content, color);
2530            // Pass through style and localId if present
2531            if let Some(ref attrs) = d.attrs {
2532                if let Some(ref mut node_attrs) = node.attrs {
2533                    if let Some(style) = attrs.get("style") {
2534                        node_attrs["style"] = serde_json::Value::String(style.to_string());
2535                    }
2536                    if let Some(local_id) = attrs.get("localId") {
2537                        node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
2538                    }
2539                }
2540            }
2541            node
2542        }
2543        "date" => {
2544            let timestamp = d
2545                .attrs
2546                .as_ref()
2547                .and_then(|a| a.get("timestamp"))
2548                .map_or_else(|| iso_date_to_epoch_ms(content), ToString::to_string);
2549            let mut node = AdfNode::date(&timestamp);
2550            pass_through_local_id(&d.attrs, &mut node);
2551            node
2552        }
2553        "mention" => {
2554            let id = d.attrs.as_ref().and_then(|a| a.get("id")).unwrap_or("");
2555            let mut node = AdfNode::mention(id, content);
2556            // Pass through optional userType and accessLevel
2557            if let Some(ref attrs) = d.attrs {
2558                if let (Some(ref mut node_attrs), true) = (
2559                    &mut node.attrs,
2560                    attrs.get("userType").is_some() || attrs.get("accessLevel").is_some(),
2561                ) {
2562                    if let Some(ut) = attrs.get("userType") {
2563                        node_attrs["userType"] = serde_json::Value::String(ut.to_string());
2564                    }
2565                    if let Some(al) = attrs.get("accessLevel") {
2566                        node_attrs["accessLevel"] = serde_json::Value::String(al.to_string());
2567                    }
2568                }
2569            }
2570            pass_through_local_id(&d.attrs, &mut node);
2571            node
2572        }
2573        "span" => {
2574            let mut marks = Vec::new();
2575            if let Some(ref attrs) = d.attrs {
2576                if let Some(color) = attrs.get("color") {
2577                    marks.push(AdfMark::text_color(color));
2578                }
2579                if let Some(bg) = attrs.get("bg") {
2580                    marks.push(AdfMark::background_color(bg));
2581                }
2582                if attrs.has_flag("sub") {
2583                    marks.push(AdfMark::subsup("sub"));
2584                }
2585                if attrs.has_flag("sup") {
2586                    marks.push(AdfMark::subsup("sup"));
2587                }
2588            }
2589            if marks.is_empty() {
2590                AdfNode::text(content)
2591            } else {
2592                // Parse inner content to handle nested syntax (e.g., links).
2593                // Prepend span marks before inner marks to preserve ordering.
2594                let inner = parse_inline_no_auto_cards(content);
2595                let mut nodes: Vec<AdfNode> = inner
2596                    .into_iter()
2597                    .map(|mut node| {
2598                        let mut combined = marks.clone();
2599                        if let Some(ref existing) = node.marks {
2600                            combined.extend(existing.iter().cloned());
2601                        }
2602                        node.marks = Some(combined);
2603                        node
2604                    })
2605                    .collect();
2606                // Return the first marked node (typical case is a single node).
2607                nodes.remove(0)
2608            }
2609        }
2610        "placeholder" => AdfNode::placeholder(content),
2611        "media-inline" => {
2612            let mut json_attrs = serde_json::Map::new();
2613            if let Some(ref attrs) = d.attrs {
2614                for key in &["type", "id", "collection", "url", "alt", "width", "height"] {
2615                    if let Some(val) = attrs.get(key) {
2616                        if *key == "width" || *key == "height" {
2617                            if let Ok(n) = val.parse::<u64>() {
2618                                json_attrs.insert(
2619                                    (*key).to_string(),
2620                                    serde_json::Value::Number(n.into()),
2621                                );
2622                                continue;
2623                            }
2624                        }
2625                        json_attrs.insert(
2626                            (*key).to_string(),
2627                            serde_json::Value::String(val.to_string()),
2628                        );
2629                    }
2630                }
2631                if let Some(local_id) = attrs.get("localId") {
2632                    json_attrs.insert(
2633                        "localId".to_string(),
2634                        serde_json::Value::String(local_id.to_string()),
2635                    );
2636                }
2637            }
2638            AdfNode::media_inline(serde_json::Value::Object(json_attrs))
2639        }
2640        "extension" => {
2641            let ext_type = d.attrs.as_ref().and_then(|a| a.get("type")).unwrap_or("");
2642            let ext_key = d.attrs.as_ref().and_then(|a| a.get("key")).unwrap_or("");
2643            AdfNode::inline_extension(ext_type, ext_key, Some(content))
2644        }
2645        _ => return None, // unknown directive — fall through to plain text
2646    };
2647
2648    Some((node, d.end_pos))
2649}
2650
2651/// Tries to parse a bare URL (`http://` or `https://`) starting at position `i`.
2652/// Scans forward until whitespace, `)`, `]`, or end of string.
2653fn try_parse_bare_url(text: &str, i: usize) -> Option<(usize, &str)> {
2654    let rest = &text[i..];
2655    if !rest.starts_with("http://") && !rest.starts_with("https://") {
2656        return None;
2657    }
2658    // URL extends to the next whitespace or delimiter
2659    let end = rest
2660        .find(|c: char| c.is_whitespace() || c == ')' || c == ']' || c == '>')
2661        .unwrap_or(rest.len());
2662    // Strip trailing punctuation that's likely not part of the URL
2663    let url = rest[..end].trim_end_matches(['.', ',', ';', '!', '?']);
2664    if url.len() <= "https://".len() {
2665        return None; // too short to be a real URL
2666    }
2667    Some((i + url.len(), url))
2668}
2669
2670/// Tries to parse an emoji shortcode `:name:` starting at position `i`.
2671/// The name must match `[a-zA-Z0-9_+-]+`.
2672fn try_parse_emoji_shortcode(text: &str, i: usize) -> Option<(usize, &str)> {
2673    let rest = &text[i..];
2674    if !rest.starts_with(':') {
2675        return None;
2676    }
2677    let after = &rest[1..];
2678    let name_end =
2679        after.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '+' && c != '-')?;
2680    if name_end == 0 {
2681        return None;
2682    }
2683    if after.as_bytes().get(name_end) != Some(&b':') {
2684        return None;
2685    }
2686    let name = &after[..name_end];
2687    Some((i + 1 + name_end + 1, name))
2688}
2689
2690/// Parses an emoji shortcode that has already been matched, then checks for
2691/// trailing `{id="..." text="..."}` attributes to preserve round-trip fidelity.
2692fn parse_emoji_with_attrs(text: &str, shortcode_end: usize, short_name: &str) -> (usize, AdfNode) {
2693    // Issue #576: An emoji with a combined shortName like `:slightly_smiling_face::bow:`
2694    // is emitted as `:slightly_smiling_face::bow:{shortName="..." ...}`. Extend the
2695    // match through any adjacent `:name:` shortcodes so that a trailing directive
2696    // attaches to the whole chain as a single emoji, using the directive's shortName.
2697    let mut chain_end = shortcode_end;
2698    while let Some((next_end, _)) = try_parse_emoji_shortcode(text, chain_end) {
2699        chain_end = next_end;
2700    }
2701    if chain_end > shortcode_end {
2702        if let Some((attr_end, attrs)) = parse_attrs(text, chain_end) {
2703            return (attr_end, build_emoji_node(&attrs, short_name));
2704        }
2705    }
2706
2707    if let Some((attr_end, attrs)) = parse_attrs(text, shortcode_end) {
2708        (attr_end, build_emoji_node(&attrs, short_name))
2709    } else {
2710        (shortcode_end, AdfNode::emoji(&format!(":{short_name}:")))
2711    }
2712}
2713
2714/// Builds an emoji `AdfNode` from parsed directive attrs, falling back to
2715/// the matched shortcode name when `shortName` is absent from the directive.
2716fn build_emoji_node(attrs: &Attrs, short_name: &str) -> AdfNode {
2717    let resolved_name = attrs
2718        .get("shortName")
2719        .map_or_else(|| format!(":{short_name}:"), str::to_string);
2720    let mut emoji_attrs = serde_json::json!({ "shortName": resolved_name });
2721    if let Some(id) = attrs.get("id") {
2722        emoji_attrs["id"] = serde_json::Value::String(id.to_string());
2723    }
2724    if let Some(t) = attrs.get("text") {
2725        emoji_attrs["text"] = serde_json::Value::String(t.to_string());
2726    }
2727    if let Some(lid) = attrs.get("localId") {
2728        emoji_attrs["localId"] = serde_json::Value::String(lid.to_string());
2729    }
2730    AdfNode {
2731        node_type: "emoji".to_string(),
2732        attrs: Some(emoji_attrs),
2733        content: None,
2734        text: None,
2735        marks: None,
2736        local_id: None,
2737        parameters: None,
2738    }
2739}
2740
2741/// Finds the closing `)` that matches the opening `(` at position `open`,
2742/// counting nested parentheses so that URLs containing `(` and `)` are
2743/// handled correctly.  Returns the index of the matching `)` relative to
2744/// the start of `s`, or `None` if no match is found.
2745fn find_closing_paren(s: &str, open: usize) -> Option<usize> {
2746    let mut depth: usize = 0;
2747    for (j, ch) in s[open..].char_indices() {
2748        match ch {
2749            '(' => depth += 1,
2750            ')' => {
2751                depth -= 1;
2752                if depth == 0 {
2753                    return Some(open + j);
2754                }
2755            }
2756            _ => {}
2757        }
2758    }
2759    None
2760}
2761
2762/// Tries to parse [text](url) starting at position `i`.
2763///
2764/// Uses bracket depth counting to find the matching `]`, so that `[` characters
2765/// inside the text (e.g. `[Task] some text ([Link](url))`) don't cause a false
2766/// match on an earlier `](`.
2767fn try_parse_link(text: &str, i: usize) -> Option<(usize, &str, &str)> {
2768    let rest = &text[i..];
2769    if !rest.starts_with('[') {
2770        return None;
2771    }
2772
2773    // Find the matching ] by counting bracket depth, skipping escaped brackets
2774    let mut depth: usize = 0;
2775    let mut text_end = None;
2776    let bytes = rest.as_bytes();
2777    for (j, ch) in rest.char_indices() {
2778        match ch {
2779            '\\' if j + 1 < bytes.len() && (bytes[j + 1] == b'[' || bytes[j + 1] == b']') => {
2780                // Skip backslash-escaped bracket (issue #493)
2781            }
2782            '[' if j == 0 || bytes[j - 1] != b'\\' => depth += 1,
2783            ']' if j == 0 || bytes[j - 1] != b'\\' => {
2784                depth -= 1;
2785                if depth == 0 {
2786                    text_end = Some(j);
2787                    break;
2788                }
2789            }
2790            _ => {}
2791        }
2792    }
2793
2794    let text_end = text_end?;
2795    let link_text = &rest[1..text_end];
2796    // Must be immediately followed by (
2797    let after_bracket = &rest[text_end + 1..];
2798    if !after_bracket.starts_with('(') {
2799        return None;
2800    }
2801    let url_start = text_end + 1; // index of the '('
2802    let url_end = find_closing_paren(rest, url_start)?;
2803    let href = &rest[url_start + 1..url_end];
2804
2805    Some((i + url_end + 1, link_text, href))
2806}
2807
2808// ── ADF → Markdown ──────────────────────────────────────────────────
2809
2810/// Options for ADF-to-markdown rendering.
2811#[derive(Debug, Clone, Default)]
2812pub struct RenderOptions {
2813    /// When true, omit `localId` attributes from directive output.
2814    pub strip_local_ids: bool,
2815}
2816
2817/// Converts an ADF document to a markdown string.
2818pub fn adf_to_markdown(doc: &AdfDocument) -> Result<String> {
2819    adf_to_markdown_with_options(doc, &RenderOptions::default())
2820}
2821
2822/// Converts an ADF document to a markdown string with options.
2823pub fn adf_to_markdown_with_options(doc: &AdfDocument, opts: &RenderOptions) -> Result<String> {
2824    let mut output = String::new();
2825
2826    for (i, node) in doc.content.iter().enumerate() {
2827        if i > 0 {
2828            output.push('\n');
2829        }
2830        render_block_node(node, &mut output, opts);
2831    }
2832
2833    Ok(output)
2834}
2835
2836/// Flattens an ADF document to plain text by concatenating all `text` nodes.
2837///
2838/// Block boundaries (paragraph, heading, list item, table cell, etc.) are
2839/// separated by a single space so a multi-paragraph anchor selection matches
2840/// when the user copies the text without trailing/leading whitespace.
2841///
2842/// Used by [`crate::atlassian::confluence_api::ConfluenceApi::resolve_anchor`]
2843/// to count occurrences of an inline-comment anchor on the live page body.
2844#[must_use]
2845pub fn adf_to_plain_text(doc: &AdfDocument) -> String {
2846    let mut out = String::new();
2847    for node in &doc.content {
2848        collect_plain_text(node, &mut out);
2849        if !out.is_empty() && !out.ends_with(' ') {
2850            out.push(' ');
2851        }
2852    }
2853    out.truncate(out.trim_end().len());
2854    out
2855}
2856
2857fn collect_plain_text(node: &AdfNode, out: &mut String) {
2858    if let Some(text) = &node.text {
2859        out.push_str(text);
2860    }
2861    if let Some(children) = &node.content {
2862        for child in children {
2863            collect_plain_text(child, out);
2864        }
2865    }
2866}
2867
2868/// Pushes a `localId=<value>` entry to an attribute parts vec,
2869/// unless `opts.strip_local_ids` is set or the value is a placeholder.
2870/// Copies `localId` from parsed directive attrs to an ADF node's attrs if present.
2871fn pass_through_local_id(dir_attrs: &Option<crate::atlassian::attrs::Attrs>, node: &mut AdfNode) {
2872    if let Some(ref attrs) = dir_attrs {
2873        if let Some(local_id) = attrs.get("localId") {
2874            if let Some(ref mut node_attrs) = node.attrs {
2875                node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
2876            } else {
2877                node.attrs = Some(serde_json::json!({"localId": local_id}));
2878            }
2879        }
2880    }
2881}
2882
2883/// Copies `localId` from directive attrs to the node's top-level `local_id` field,
2884/// and parses `params` JSON from directive attrs into the node's `parameters` field.
2885fn pass_through_expand_params(
2886    dir_attrs: &Option<crate::atlassian::attrs::Attrs>,
2887    node: &mut AdfNode,
2888) {
2889    if let Some(ref attrs) = dir_attrs {
2890        if let Some(local_id) = attrs.get("localId") {
2891            node.local_id = Some(local_id.to_string());
2892        }
2893        if let Some(params_str) = attrs.get("params") {
2894            if let Ok(params) = serde_json::from_str(params_str) {
2895                node.parameters = Some(params);
2896            }
2897        }
2898    }
2899}
2900
2901// listItem localId is emitted as trailing inline attrs on the item line
2902// (e.g., `- item text {localId=...}`) and parsed back by extracting
2903// trailing attrs from the list item text. This avoids the block-attrs
2904// promotion issue where {localId=...} on a separate line would be
2905// applied to the parent list node.
2906
2907/// Extracts trailing `{localId=... paraLocalId=...}` from list item text.
2908/// Returns the text without the trailing attrs, the listItem localId, and
2909/// the paragraph localId if found.
2910fn extract_trailing_local_id(text: &str) -> (&str, Option<String>, Option<String>) {
2911    let trimmed = text.trim_end();
2912    if !trimmed.ends_with('}') {
2913        return (text, None, None);
2914    }
2915    // Find the opening brace.  Only match a standalone `{…}` block that is
2916    // preceded by whitespace (or is at the start of the string).  A `{` that
2917    // immediately follows `]` is part of an inline directive (e.g.
2918    // `:mention[text]{id=… localId=…}`) and must NOT be consumed here.
2919    if let Some(brace_pos) = trimmed.rfind('{') {
2920        if brace_pos > 0 && !trimmed.as_bytes()[brace_pos - 1].is_ascii_whitespace() {
2921            return (text, None, None);
2922        }
2923        let attr_str = &trimmed[brace_pos..];
2924        if let Some((_, attrs)) = parse_attrs(attr_str, 0) {
2925            let local_id = attrs.get("localId").map(str::to_string);
2926            let para_local_id = attrs.get("paraLocalId").map(str::to_string);
2927            if local_id.is_some() || para_local_id.is_some() {
2928                let before = trimmed[..brace_pos]
2929                    .strip_suffix(' ')
2930                    .unwrap_or(&trimmed[..brace_pos]);
2931                return (before, local_id, para_local_id);
2932            }
2933        }
2934    }
2935    (text, None, None)
2936}
2937
2938/// Creates a `listItem` node, optionally with a `localId` attribute
2939/// and a `paraLocalId` on its first paragraph child.
2940/// Parses the first line of a list item and any indented sub-content into
2941/// an `AdfNode::list_item`.  When the first line is a code fence opener
2942/// (`` ``` ``), the line is folded into the sub-content so the block-level
2943/// code fence parser handles it correctly (issue #511).
2944fn parse_list_item_first_line(
2945    item_text: &str,
2946    sub_lines: Vec<String>,
2947    local_id: Option<String>,
2948    para_local_id: Option<String>,
2949) -> Result<AdfNode> {
2950    if item_text.starts_with("```") {
2951        // Treat the code fence opener + indented body as block content.
2952        let mut all_lines = vec![item_text.to_string()];
2953        all_lines.extend(sub_lines);
2954        let combined = all_lines.join("\n");
2955        let nested = MarkdownParser::new(&combined).parse_blocks()?;
2956        Ok(list_item_with_local_id(nested, local_id, para_local_id))
2957    } else if let Some(media) = try_parse_media_single_from_line(item_text) {
2958        // Block-level image (issue #430).
2959        if sub_lines.is_empty() {
2960            Ok(list_item_with_local_id(
2961                vec![media],
2962                local_id,
2963                para_local_id,
2964            ))
2965        } else {
2966            let sub_text = sub_lines.join("\n");
2967            let mut nested = MarkdownParser::new(&sub_text).parse_blocks()?;
2968            let mut content = vec![media];
2969            content.append(&mut nested);
2970            Ok(list_item_with_local_id(content, local_id, para_local_id))
2971        }
2972    } else {
2973        let first_node = AdfNode::paragraph(parse_inline(item_text));
2974        if sub_lines.is_empty() {
2975            Ok(list_item_with_local_id(
2976                vec![first_node],
2977                local_id,
2978                para_local_id,
2979            ))
2980        } else {
2981            let sub_text = sub_lines.join("\n");
2982            let mut nested = MarkdownParser::new(&sub_text).parse_blocks()?;
2983            let mut content = vec![first_node];
2984            content.append(&mut nested);
2985            Ok(list_item_with_local_id(content, local_id, para_local_id))
2986        }
2987    }
2988}
2989
2990fn list_item_with_local_id(
2991    mut content: Vec<AdfNode>,
2992    local_id: Option<String>,
2993    para_local_id: Option<String>,
2994) -> AdfNode {
2995    if let Some(id) = &para_local_id {
2996        if let Some(first) = content.first_mut() {
2997            if first.node_type == "paragraph" {
2998                let node_attrs = first.attrs.get_or_insert_with(|| serde_json::json!({}));
2999                node_attrs["localId"] = serde_json::Value::String(id.clone());
3000            }
3001        }
3002    }
3003    let mut item = AdfNode::list_item(content);
3004    if let Some(id) = local_id {
3005        item.attrs = Some(serde_json::json!({"localId": id}));
3006    }
3007    item
3008}
3009
3010fn maybe_push_local_id(attrs: &serde_json::Value, parts: &mut Vec<String>, opts: &RenderOptions) {
3011    if opts.strip_local_ids {
3012        return;
3013    }
3014    if let Some(local_id) = attrs.get("localId").and_then(serde_json::Value::as_str) {
3015        if !local_id.is_empty() && local_id != "00000000-0000-0000-0000-000000000000" {
3016            parts.push(format_kv("localId", local_id));
3017        }
3018    }
3019}
3020
3021/// Renders a sequence of block nodes with blank-line separators between them.
3022fn render_block_children(children: &[AdfNode], output: &mut String, opts: &RenderOptions) {
3023    for (i, child) in children.iter().enumerate() {
3024        if i > 0 {
3025            output.push('\n');
3026        }
3027        render_block_node(child, output, opts);
3028    }
3029}
3030
3031/// Formats a float as an integer string when it has no fractional part,
3032/// otherwise as a regular float string.
3033fn fmt_f64_attr(v: f64) -> String {
3034    if v.fract() == 0.0 {
3035        format!("{}", v as i64)
3036    } else {
3037        v.to_string()
3038    }
3039}
3040
3041/// Parses a numeric attribute value string into a JSON number value that
3042/// preserves the original integer/float distinction. Returns `None` if the
3043/// string cannot be parsed as a number.
3044///
3045/// Strings without a `.` or exponent are parsed as integers (so `"800"` stays
3046/// `800`, not `800.0`); strings with a decimal point are parsed as floats.
3047fn parse_numeric_attr(s: &str) -> Option<serde_json::Value> {
3048    if s.contains('.') || s.contains('e') || s.contains('E') {
3049        s.parse::<f64>().ok().map(serde_json::Value::from)
3050    } else {
3051        s.parse::<i64>().ok().map(serde_json::Value::from)
3052    }
3053}
3054
3055/// Formats a JSON numeric value as a markdown attribute string, preserving
3056/// whether the source was stored as an integer or a float.
3057///
3058/// Returns `None` if `v` is not a number. Integer values emit as `800`;
3059/// floating-point values emit as `800.0` (or `66.66` for non-integer floats),
3060/// so that a subsequent [`parse_numeric_attr`] round-trip recovers the same
3061/// JSON type.
3062fn fmt_numeric_attr(v: &serde_json::Value) -> Option<String> {
3063    if let Some(n) = v.as_i64() {
3064        return Some(n.to_string());
3065    }
3066    if let Some(n) = v.as_u64() {
3067        return Some(n.to_string());
3068    }
3069    if let Some(n) = v.as_f64() {
3070        if n.fract() == 0.0 && n.is_finite() {
3071            return Some(format!("{n:.1}"));
3072        }
3073        return Some(n.to_string());
3074    }
3075    None
3076}
3077
3078/// Renders a block-level ADF node to markdown.
3079fn render_block_node(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
3080    match node.node_type.as_str() {
3081        "paragraph" => {
3082            let is_empty = node.content.as_ref().map_or(true, Vec::is_empty);
3083            // Build directive attr string for localId when using ::paragraph form
3084            let dir_attrs = {
3085                let mut parts = Vec::new();
3086                if let Some(ref attrs) = node.attrs {
3087                    maybe_push_local_id(attrs, &mut parts, opts);
3088                }
3089                if parts.is_empty() {
3090                    String::new()
3091                } else {
3092                    format!("{{{}}}", parts.join(" "))
3093                }
3094            };
3095            if is_empty {
3096                output.push_str(&format!("::paragraph{dir_attrs}\n"));
3097            } else {
3098                // Render to a buffer first to check if content is whitespace-only
3099                let mut buf = String::new();
3100                render_inline_content(node, &mut buf, opts);
3101                if buf.trim().is_empty() && !buf.is_empty() {
3102                    // Whitespace-only content (e.g. NBSP) would be lost as a plain
3103                    // line — use the ::paragraph[content]{attrs} directive form
3104                    output.push_str(&format!("::paragraph[{buf}]{dir_attrs}\n"));
3105                } else {
3106                    // Escape a leading list-marker pattern so paragraph
3107                    // text is not re-parsed as a list item (issue #402).
3108                    // Indent continuation lines produced by hardBreaks so
3109                    // they are not re-parsed as list items (issue #455).
3110                    let mut is_first_line = true;
3111                    for line in buf.split('\n') {
3112                        if is_first_line {
3113                            if is_list_start(line) {
3114                                output.push_str(&escape_list_marker(line));
3115                            } else {
3116                                output.push_str(line);
3117                            }
3118                            is_first_line = false;
3119                        } else {
3120                            output.push('\n');
3121                            if !line.is_empty() {
3122                                output.push_str("  ");
3123                            }
3124                            output.push_str(line);
3125                        }
3126                    }
3127                    output.push('\n');
3128                }
3129            }
3130        }
3131        "heading" => {
3132            let level = node
3133                .attrs
3134                .as_ref()
3135                .and_then(|a| a.get("level"))
3136                .and_then(serde_json::Value::as_u64)
3137                .unwrap_or(1);
3138            for _ in 0..level {
3139                output.push('#');
3140            }
3141            output.push(' ');
3142            let mut buf = String::new();
3143            render_inline_content(node, &mut buf, opts);
3144            // Indent continuation lines produced by hardBreaks so they stay
3145            // within the heading when re-parsed (issue #433).
3146            let mut is_first_line = true;
3147            for line in buf.split('\n') {
3148                if is_first_line {
3149                    output.push_str(line);
3150                    is_first_line = false;
3151                } else {
3152                    output.push('\n');
3153                    if !line.is_empty() {
3154                        output.push_str("  ");
3155                    }
3156                    output.push_str(line);
3157                }
3158            }
3159            output.push('\n');
3160        }
3161        "codeBlock" => {
3162            let language_value = node.attrs.as_ref().and_then(|a| a.get("language"));
3163            let language = language_value
3164                .and_then(serde_json::Value::as_str)
3165                .unwrap_or("");
3166            output.push_str("```");
3167            if language.is_empty() && language_value.is_some() {
3168                // Explicit empty language attr: encode as ```"" to distinguish
3169                // from a codeBlock with no attrs at all (plain ```).
3170                output.push_str("\"\"");
3171            } else {
3172                output.push_str(language);
3173            }
3174            output.push('\n');
3175            if let Some(ref content) = node.content {
3176                for child in content {
3177                    if let Some(ref text) = child.text {
3178                        output.push_str(text);
3179                    }
3180                }
3181            }
3182            output.push_str("\n```\n");
3183        }
3184        "blockquote" => {
3185            if let Some(ref content) = node.content {
3186                for (i, child) in content.iter().enumerate() {
3187                    // Separate consecutive paragraph siblings with a blank
3188                    // blockquote-prefixed line so they re-parse as distinct
3189                    // paragraphs rather than being merged into one (issue #531).
3190                    if i > 0
3191                        && child.node_type == "paragraph"
3192                        && content[i - 1].node_type == "paragraph"
3193                    {
3194                        output.push_str(">\n");
3195                    }
3196                    let mut inner = String::new();
3197                    render_block_node(child, &mut inner, opts);
3198                    for line in inner.lines() {
3199                        output.push_str("> ");
3200                        output.push_str(line);
3201                        output.push('\n');
3202                    }
3203                }
3204            }
3205        }
3206        "bulletList" => {
3207            if let Some(ref items) = node.content {
3208                for item in items {
3209                    output.push_str("- ");
3210                    let content_start = output.len();
3211                    render_list_item_content(item, output, opts);
3212                    // If the rendered content begins with a sequence the
3213                    // bullet-list parser would interpret as a task checkbox
3214                    // marker, escape the leading `[` so the round-trip
3215                    // preserves this as a `bulletList` rather than promoting
3216                    // it to a `taskList` (issue #548).
3217                    if starts_with_task_marker(&output[content_start..]) {
3218                        output.insert(content_start, '\\');
3219                    }
3220                }
3221            }
3222        }
3223        "orderedList" => {
3224            let start = node
3225                .attrs
3226                .as_ref()
3227                .and_then(|a| a.get("order"))
3228                .and_then(serde_json::Value::as_u64)
3229                .unwrap_or(1);
3230            if let Some(ref items) = node.content {
3231                for (i, item) in items.iter().enumerate() {
3232                    let num = start + i as u64;
3233                    output.push_str(&format!("{num}. "));
3234                    render_list_item_content(item, output, opts);
3235                }
3236            }
3237        }
3238        "taskList" => {
3239            if let Some(ref items) = node.content {
3240                for item in items {
3241                    if item.node_type == "taskList" {
3242                        // A nested taskList is a sibling child of the outer
3243                        // taskList — render it indented so it round-trips back
3244                        // as a taskList, not a taskItem (issue #506).
3245                        let mut nested = String::new();
3246                        render_block_node(item, &mut nested, opts);
3247                        for line in nested.lines() {
3248                            output.push_str("  ");
3249                            output.push_str(line);
3250                            output.push('\n');
3251                        }
3252                    } else {
3253                        let state = item
3254                            .attrs
3255                            .as_ref()
3256                            .and_then(|a| a.get("state"))
3257                            .and_then(serde_json::Value::as_str)
3258                            .unwrap_or("TODO");
3259                        if state == "DONE" {
3260                            output.push_str("- [x] ");
3261                        } else {
3262                            output.push_str("- [ ] ");
3263                        }
3264                        render_list_item_content(item, output, opts);
3265                    }
3266                }
3267            }
3268        }
3269        "rule" => {
3270            output.push_str("---\n");
3271        }
3272        "table" => {
3273            render_table(node, output, opts);
3274        }
3275        "mediaSingle" => {
3276            if let Some(ref content) = node.content {
3277                for child in content {
3278                    if child.node_type == "media" {
3279                        render_media(child, node.attrs.as_ref(), output, opts);
3280                    }
3281                }
3282                for child in content {
3283                    if child.node_type == "caption" {
3284                        let mut cap_parts = Vec::new();
3285                        if let Some(ref attrs) = child.attrs {
3286                            maybe_push_local_id(attrs, &mut cap_parts, opts);
3287                        }
3288                        if cap_parts.is_empty() {
3289                            output.push_str(":::caption\n");
3290                        } else {
3291                            output.push_str(&format!(":::caption{{{}}}\n", cap_parts.join(" ")));
3292                        }
3293                        if let Some(ref caption_content) = child.content {
3294                            for inline in caption_content {
3295                                render_inline_node(inline, output, opts);
3296                            }
3297                            output.push('\n');
3298                        }
3299                        output.push_str(":::\n");
3300                    }
3301                }
3302            }
3303        }
3304        "blockCard" => {
3305            if let Some(ref attrs) = node.attrs {
3306                let url = attrs
3307                    .get("url")
3308                    .and_then(serde_json::Value::as_str)
3309                    .unwrap_or("");
3310                let mut attr_parts = Vec::new();
3311                if url_safe_in_bracket_content(url) {
3312                    output.push_str(&format!("::card[{url}]"));
3313                } else {
3314                    // URL would break `::card[URL]` parsing; use quoted attr form.
3315                    output.push_str("::card[]");
3316                    let escaped = url.replace('\\', "\\\\").replace('"', "\\\"");
3317                    attr_parts.push(format!("url=\"{escaped}\""));
3318                }
3319                if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3320                    attr_parts.push(format!("layout={layout}"));
3321                }
3322                if let Some(width) = attrs.get("width").and_then(serde_json::Value::as_u64) {
3323                    attr_parts.push(format!("width={width}"));
3324                }
3325                if !attr_parts.is_empty() {
3326                    output.push_str(&format!("{{{}}}", attr_parts.join(" ")));
3327                }
3328                output.push('\n');
3329            }
3330        }
3331        "embedCard" => {
3332            if let Some(ref attrs) = node.attrs {
3333                let url = attrs
3334                    .get("url")
3335                    .and_then(serde_json::Value::as_str)
3336                    .unwrap_or("");
3337                output.push_str(&format!("::embed[{url}]"));
3338                let mut attr_parts = Vec::new();
3339                if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3340                    attr_parts.push(format!("layout={layout}"));
3341                }
3342                if let Some(h) = attrs
3343                    .get("originalHeight")
3344                    .and_then(serde_json::Value::as_f64)
3345                {
3346                    attr_parts.push(format!("originalHeight={}", fmt_f64_attr(h)));
3347                }
3348                if let Some(w) = attrs.get("width").and_then(serde_json::Value::as_f64) {
3349                    attr_parts.push(format!("width={}", fmt_f64_attr(w)));
3350                }
3351                if !attr_parts.is_empty() {
3352                    output.push_str(&format!("{{{}}}", attr_parts.join(" ")));
3353                }
3354                output.push('\n');
3355            }
3356        }
3357        "extension" => {
3358            if let Some(ref attrs) = node.attrs {
3359                let ext_type = attrs
3360                    .get("extensionType")
3361                    .and_then(serde_json::Value::as_str)
3362                    .unwrap_or("");
3363                let ext_key = attrs
3364                    .get("extensionKey")
3365                    .and_then(serde_json::Value::as_str)
3366                    .unwrap_or("");
3367                let mut attr_parts = vec![format!("type={ext_type}"), format!("key={ext_key}")];
3368                if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3369                    attr_parts.push(format!("layout={layout}"));
3370                }
3371                if let Some(params) = attrs.get("parameters") {
3372                    if let Ok(json_str) = serde_json::to_string(params) {
3373                        attr_parts.push(format!("params='{json_str}'"));
3374                    }
3375                }
3376                maybe_push_local_id(attrs, &mut attr_parts, opts);
3377                output.push_str(&format!("::extension{{{}}}\n", attr_parts.join(" ")));
3378            }
3379        }
3380        "panel" => {
3381            let panel_type = node
3382                .attrs
3383                .as_ref()
3384                .and_then(|a| a.get("panelType"))
3385                .and_then(serde_json::Value::as_str)
3386                .unwrap_or("info");
3387            let mut attr_parts = vec![format!("type={panel_type}")];
3388            if let Some(ref attrs) = node.attrs {
3389                if let Some(icon) = attrs.get("panelIcon").and_then(serde_json::Value::as_str) {
3390                    attr_parts.push(format!("icon=\"{icon}\""));
3391                }
3392                if let Some(color) = attrs.get("panelColor").and_then(serde_json::Value::as_str) {
3393                    attr_parts.push(format!("color=\"{color}\""));
3394                }
3395            }
3396            output.push_str(&format!(":::panel{{{}}}\n", attr_parts.join(" ")));
3397            if let Some(ref content) = node.content {
3398                render_block_children(content, output, opts);
3399            }
3400            output.push_str(":::\n");
3401        }
3402        "expand" | "nestedExpand" => {
3403            let directive_name = if node.node_type == "nestedExpand" {
3404                "nested-expand"
3405            } else {
3406                "expand"
3407            };
3408            let mut attr_parts = Vec::new();
3409            if let Some(t) = node
3410                .attrs
3411                .as_ref()
3412                .and_then(|a| a.get("title"))
3413                .and_then(serde_json::Value::as_str)
3414            {
3415                attr_parts.push(format!("title=\"{t}\""));
3416            }
3417            // Check top-level localId first, then fall back to attrs.localId
3418            if let Some(ref lid) = node.local_id {
3419                if !opts.strip_local_ids && lid != "00000000-0000-0000-0000-000000000000" {
3420                    attr_parts.push(format!("localId={lid}"));
3421                }
3422            } else if let Some(ref attrs) = node.attrs {
3423                maybe_push_local_id(attrs, &mut attr_parts, opts);
3424            }
3425            // Emit top-level parameters as params='...'
3426            if let Some(ref params) = node.parameters {
3427                if let Ok(json_str) = serde_json::to_string(params) {
3428                    attr_parts.push(format!("params='{json_str}'"));
3429                }
3430            }
3431            if attr_parts.is_empty() {
3432                output.push_str(&format!(":::{directive_name}\n"));
3433            } else {
3434                output.push_str(&format!(
3435                    ":::{directive_name}{{{}}}\n",
3436                    attr_parts.join(" ")
3437                ));
3438            }
3439            if let Some(ref content) = node.content {
3440                render_block_children(content, output, opts);
3441            }
3442            output.push_str(":::\n");
3443        }
3444        "layoutSection" => {
3445            output.push_str("::::layout\n");
3446            if let Some(ref content) = node.content {
3447                for child in content {
3448                    if child.node_type == "layoutColumn" {
3449                        let width_str = child
3450                            .attrs
3451                            .as_ref()
3452                            .and_then(|a| a.get("width"))
3453                            .and_then(fmt_numeric_attr)
3454                            .unwrap_or_else(|| "50".to_string());
3455                        let mut parts = vec![format!("width={width_str}")];
3456                        if let Some(ref attrs) = child.attrs {
3457                            maybe_push_local_id(attrs, &mut parts, opts);
3458                        }
3459                        output.push_str(&format!(":::column{{{}}}\n", parts.join(" ")));
3460                        if let Some(ref col_content) = child.content {
3461                            render_block_children(col_content, output, opts);
3462                        }
3463                        output.push_str(":::\n");
3464                    }
3465                }
3466            }
3467            output.push_str("::::\n");
3468        }
3469        "decisionList" => {
3470            output.push_str(":::decisions\n");
3471            if let Some(ref content) = node.content {
3472                for item in content {
3473                    output.push_str("- <> ");
3474                    render_list_item_content(item, output, opts);
3475                }
3476            }
3477            output.push_str(":::\n");
3478        }
3479        "bodiedExtension" => {
3480            if let Some(ref attrs) = node.attrs {
3481                let ext_type = attrs
3482                    .get("extensionType")
3483                    .and_then(serde_json::Value::as_str)
3484                    .unwrap_or("");
3485                let ext_key = attrs
3486                    .get("extensionKey")
3487                    .and_then(serde_json::Value::as_str)
3488                    .unwrap_or("");
3489                let mut attr_parts = vec![format!("type={ext_type}"), format!("key={ext_key}")];
3490                if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3491                    attr_parts.push(format!("layout={layout}"));
3492                }
3493                if let Some(params) = attrs.get("parameters") {
3494                    if let Ok(json_str) = serde_json::to_string(params) {
3495                        attr_parts.push(format!("params='{json_str}'"));
3496                    }
3497                }
3498                maybe_push_local_id(attrs, &mut attr_parts, opts);
3499                output.push_str(&format!(":::extension{{{}}}\n", attr_parts.join(" ")));
3500                if let Some(ref content) = node.content {
3501                    render_block_children(content, output, opts);
3502                }
3503                output.push_str(":::\n");
3504            }
3505        }
3506        _ => {
3507            // Preserve unsupported nodes as JSON in adf-unsupported code blocks
3508            if let Ok(json) = serde_json::to_string_pretty(node) {
3509                output.push_str("```adf-unsupported\n");
3510                output.push_str(&json);
3511                output.push_str("\n```\n");
3512            }
3513        }
3514    }
3515
3516    // Emit block-level attribute marks (align, indent, breakout) and localId
3517    let mut parts = Vec::new();
3518    if let Some(ref marks) = node.marks {
3519        for mark in marks {
3520            match mark.mark_type.as_str() {
3521                "alignment" => {
3522                    if let Some(align) = mark
3523                        .attrs
3524                        .as_ref()
3525                        .and_then(|a| a.get("align"))
3526                        .and_then(serde_json::Value::as_str)
3527                    {
3528                        parts.push(format!("align={align}"));
3529                    }
3530                }
3531                "indentation" => {
3532                    if let Some(level) = mark
3533                        .attrs
3534                        .as_ref()
3535                        .and_then(|a| a.get("level"))
3536                        .and_then(serde_json::Value::as_u64)
3537                    {
3538                        parts.push(format!("indent={level}"));
3539                    }
3540                }
3541                "breakout" => {
3542                    if let Some(mode) = mark
3543                        .attrs
3544                        .as_ref()
3545                        .and_then(|a| a.get("mode"))
3546                        .and_then(serde_json::Value::as_str)
3547                    {
3548                        parts.push(format!("breakout={mode}"));
3549                    }
3550                    if let Some(width) = mark
3551                        .attrs
3552                        .as_ref()
3553                        .and_then(|a| a.get("width"))
3554                        .and_then(serde_json::Value::as_u64)
3555                    {
3556                        parts.push(format!("breakoutWidth={width}"));
3557                    }
3558                }
3559                _ => {}
3560            }
3561        }
3562    }
3563    // Skip localId for node types that already include it in their directive attrs.
3564    // For paragraphs, localId is included in the ::paragraph directive when the
3565    // paragraph uses directive form (empty or whitespace-only content).
3566    let para_used_directive = node.node_type == "paragraph" && {
3567        let is_empty = node.content.as_ref().map_or(true, Vec::is_empty);
3568        if is_empty {
3569            true
3570        } else {
3571            let mut buf = String::new();
3572            render_inline_content(node, &mut buf, opts);
3573            buf.trim().is_empty() && !buf.is_empty()
3574        }
3575    };
3576    if !matches!(node.node_type.as_str(), "expand" | "nestedExpand") && !para_used_directive {
3577        if let Some(ref attrs) = node.attrs {
3578            maybe_push_local_id(attrs, &mut parts, opts);
3579        }
3580    }
3581    // orderedList with explicit `attrs.order=1` needs a trailing `{order=1}`
3582    // signal so the round-trip can distinguish explicit default from omitted
3583    // attrs (issue #547). Values other than 1 are already encoded by the
3584    // list marker, so no signal is needed.
3585    if node.node_type == "orderedList" {
3586        if let Some(ref attrs) = node.attrs {
3587            if attrs.get("order").and_then(serde_json::Value::as_u64) == Some(1) {
3588                parts.push("order=1".to_string());
3589            }
3590        }
3591    }
3592    if !parts.is_empty() {
3593        output.push_str(&format!("{{{}}}\n", parts.join(" ")));
3594    }
3595}
3596
3597/// Renders the content of a list item (unwraps the paragraph layer).
3598/// Nested block children (e.g. sub-lists) are indented with two spaces.
3599///
3600/// Some ADF producers (e.g. Confluence) emit `taskItem` content without a
3601/// paragraph wrapper — the inline nodes sit directly inside the item.  We
3602/// detect this by checking whether the first child is an inline node type
3603/// and, if so, render *all* leading inline children on the first line.
3604fn render_list_item_content(item: &AdfNode, output: &mut String, opts: &RenderOptions) {
3605    let Some(ref content) = item.content else {
3606        // Still emit localId and newline for items with no content (e.g. empty taskItem).
3607        let bare = AdfNode::text("");
3608        emit_list_item_local_ids(item, &bare, output, opts);
3609        output.push('\n');
3610        return;
3611    };
3612    if content.is_empty() {
3613        let bare = AdfNode::text("");
3614        emit_list_item_local_ids(item, &bare, output, opts);
3615        output.push('\n');
3616        return;
3617    }
3618    let first = &content[0];
3619    let rest_start;
3620    if first.node_type == "paragraph" {
3621        let mut buf = String::new();
3622        render_inline_content(first, &mut buf, opts);
3623        // A trailing hardBreak produces a trailing `\\\n` in the buffer.
3624        // Strip the final newline so it doesn't create a blank line after
3625        // the list item marker, which would split the list on re-parse
3626        // (issue #472).  The `\` is kept so round-trip preserves the
3627        // hardBreak, and `output.push('\n')` below supplies the terminator.
3628        let buf = buf.trim_end_matches('\n');
3629        // Indent continuation lines produced by hardBreaks so they stay
3630        // within the list item when re-parsed (issue #402).
3631        let mut is_first_line = true;
3632        for line in buf.split('\n') {
3633            if is_first_line {
3634                output.push_str(line);
3635                is_first_line = false;
3636            } else {
3637                output.push('\n');
3638                if !line.is_empty() {
3639                    output.push_str("  ");
3640                }
3641                output.push_str(line);
3642            }
3643        }
3644        // Emit paragraph + listItem localIds as trailing inline attrs on the first line
3645        emit_list_item_local_ids(item, first, output, opts);
3646        output.push('\n');
3647        rest_start = 1;
3648    } else if is_inline_node_type(&first.node_type) {
3649        // Inline nodes without a paragraph wrapper — render them directly.
3650        rest_start = content
3651            .iter()
3652            .position(|c| !is_inline_node_type(&c.node_type))
3653            .unwrap_or(content.len());
3654        let mut buf = String::new();
3655        for child in &content[..rest_start] {
3656            render_inline_node(child, &mut buf, opts);
3657        }
3658        // Indent continuation lines produced by hardBreaks so they stay
3659        // within the list item when re-parsed (issue #521).
3660        let buf = buf.trim_end_matches('\n');
3661        let mut is_first_line = true;
3662        for line in buf.split('\n') {
3663            if is_first_line {
3664                output.push_str(line);
3665                is_first_line = false;
3666            } else {
3667                output.push('\n');
3668                if !line.is_empty() {
3669                    output.push_str("  ");
3670                }
3671                output.push_str(line);
3672            }
3673        }
3674        // No paragraph wrapper — pass a bare node so paraLocalId is omitted.
3675        let bare = AdfNode::text("");
3676        emit_list_item_local_ids(item, &bare, output, opts);
3677        output.push('\n');
3678        // Any remaining children are block nodes — fall through to the
3679        // indented-block loop below.
3680    } else if first.node_type == "taskItem" {
3681        // Malformed ADF: taskItem.content contains nested taskItem nodes
3682        // directly (seen in some Confluence pages).  Render them as an
3683        // indented nested task list to preserve the data without
3684        // corrupting the surrounding structure (issue #489).
3685        let bare = AdfNode::text("");
3686        emit_list_item_local_ids(item, &bare, output, opts);
3687        output.push('\n');
3688        for child in content {
3689            if child.node_type == "taskItem" {
3690                let state = child
3691                    .attrs
3692                    .as_ref()
3693                    .and_then(|a| a.get("state"))
3694                    .and_then(serde_json::Value::as_str)
3695                    .unwrap_or("TODO");
3696                let marker = if state == "DONE" { "- [x] " } else { "- [ ] " };
3697                output.push_str("  ");
3698                output.push_str(marker);
3699                render_list_item_content(child, output, opts);
3700            } else {
3701                let mut nested = String::new();
3702                render_block_node(child, &mut nested, opts);
3703                for line in nested.lines() {
3704                    output.push_str("  ");
3705                    output.push_str(line);
3706                    output.push('\n');
3707                }
3708            }
3709        }
3710        return;
3711    } else {
3712        // Block-level first child (e.g. codeBlock, mediaSingle).
3713        // Render to a buffer so we can:
3714        //  1. Append listItem localId attrs to the first line (issue #525)
3715        //  2. Indent continuation lines so multi-line blocks stay inside
3716        //     the list item (issue #511)
3717        let mut buf = String::new();
3718        render_block_node(first, &mut buf, opts);
3719        let bare = AdfNode::text("");
3720        let mut is_first = true;
3721        for line in buf.lines() {
3722            if is_first {
3723                output.push_str(line);
3724                emit_list_item_local_ids(item, &bare, output, opts);
3725                output.push('\n');
3726                is_first = false;
3727            } else {
3728                output.push_str("  ");
3729                output.push_str(line);
3730                output.push('\n');
3731            }
3732        }
3733        rest_start = 1;
3734    }
3735    let rest = &content[rest_start..];
3736    for (i, child) in rest.iter().enumerate() {
3737        // Separate consecutive paragraph siblings with a blank indented
3738        // line so they re-parse as distinct paragraphs rather than being
3739        // merged into one (issue #522).
3740        if child.node_type == "paragraph" {
3741            let prev_is_para = if i == 0 {
3742                // First rest child — check whether the first-line node
3743                // (rendered above) was a paragraph.
3744                first.node_type == "paragraph"
3745            } else {
3746                rest[i - 1].node_type == "paragraph"
3747            };
3748            if prev_is_para {
3749                output.push_str("  \n");
3750            }
3751        }
3752        let mut nested = String::new();
3753        render_block_node(child, &mut nested, opts);
3754        for line in nested.lines() {
3755            output.push_str("  ");
3756            output.push_str(line);
3757            output.push('\n');
3758        }
3759    }
3760}
3761
3762/// Returns `true` if the given ADF node type is an inline node.
3763fn is_inline_node_type(node_type: &str) -> bool {
3764    matches!(
3765        node_type,
3766        "text"
3767            | "hardBreak"
3768            | "inlineCard"
3769            | "emoji"
3770            | "mention"
3771            | "status"
3772            | "date"
3773            | "placeholder"
3774            | "mediaInline"
3775    )
3776}
3777
3778/// Emits trailing `{localId=... paraLocalId=...}` on a list item line
3779/// for both the listItem and its first (unwrapped) paragraph.
3780fn emit_list_item_local_ids(
3781    item: &AdfNode,
3782    paragraph: &AdfNode,
3783    output: &mut String,
3784    opts: &RenderOptions,
3785) {
3786    if opts.strip_local_ids {
3787        return;
3788    }
3789    let mut parts = Vec::new();
3790    if let Some(ref attrs) = item.attrs {
3791        maybe_push_local_id(attrs, &mut parts, opts);
3792    }
3793    if paragraph.node_type == "paragraph" {
3794        let has_real_id = paragraph
3795            .attrs
3796            .as_ref()
3797            .and_then(|a| a.get("localId"))
3798            .and_then(serde_json::Value::as_str)
3799            .filter(|id| !id.is_empty() && *id != "00000000-0000-0000-0000-000000000000");
3800        if let Some(local_id) = has_real_id {
3801            parts.push(format!("paraLocalId={local_id}"));
3802        } else if item.node_type == "taskItem" {
3803            // taskItem content may or may not have a paragraph wrapper;
3804            // emit a sentinel so the round-trip can distinguish the two
3805            // forms and restore the wrapper (issue #478).
3806            parts.push("paraLocalId=_".to_string());
3807        }
3808    }
3809    if !parts.is_empty() {
3810        output.push_str(&format!(" {{{}}}", parts.join(" ")));
3811    }
3812}
3813
3814/// Renders a table node, choosing between pipe table and directive table form.
3815fn render_table(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
3816    let Some(ref rows) = node.content else {
3817        return;
3818    };
3819
3820    if table_qualifies_for_pipe_syntax(rows) {
3821        render_pipe_table(node, rows, output, opts);
3822    } else {
3823        render_directive_table(node, rows, output, opts);
3824    }
3825}
3826
3827/// Checks whether all cells qualify for GFM pipe table syntax:
3828/// - Every cell has exactly one paragraph child with only inline nodes
3829/// - All `tableHeader` nodes appear exclusively in the first row
3830/// - The first row must contain at least one `tableHeader` (pipe tables
3831///   always treat the first row as headers, so `tableCell`-only first rows
3832///   must use directive form to preserve the cell type)
3833fn table_qualifies_for_pipe_syntax(rows: &[AdfNode]) -> bool {
3834    // Tables with caption nodes must use directive form
3835    if rows.iter().any(|n| n.node_type == "caption") {
3836        return false;
3837    }
3838    let mut first_row_has_header = false;
3839    for (row_idx, row) in rows.iter().enumerate() {
3840        let Some(ref cells) = row.content else {
3841            continue;
3842        };
3843        for cell in cells {
3844            // Header cells outside first row → must use directive form
3845            if row_idx > 0 && cell.node_type == "tableHeader" {
3846                return false;
3847            }
3848            if row_idx == 0 && cell.node_type == "tableHeader" {
3849                first_row_has_header = true;
3850            }
3851            // Check cell has exactly one paragraph with only inline content
3852            let Some(ref content) = cell.content else {
3853                continue;
3854            };
3855            if content.len() != 1 || content[0].node_type != "paragraph" {
3856                return false;
3857            }
3858            // hardBreak inside a cell produces a newline that breaks pipe
3859            // table syntax — fall back to directive form
3860            if cell_contains_hard_break(&content[0]) {
3861                return false;
3862            }
3863            // Cell-level marks (e.g., border) cannot be represented in pipe
3864            // form — fall back to directive form
3865            if cell.marks.as_ref().is_some_and(|m| !m.is_empty()) {
3866                return false;
3867            }
3868            // Paragraph-level localId would be lost in pipe form (the paragraph
3869            // is unwrapped into the cell text) — fall back to directive form
3870            if content[0]
3871                .attrs
3872                .as_ref()
3873                .and_then(|a| a.get("localId"))
3874                .is_some()
3875            {
3876                return false;
3877            }
3878        }
3879    }
3880    // First row must have at least one tableHeader for pipe syntax;
3881    // otherwise the round-trip would convert tableCell → tableHeader.
3882    first_row_has_header
3883}
3884
3885/// Returns true if a paragraph node contains any `hardBreak` inline nodes.
3886fn cell_contains_hard_break(paragraph: &AdfNode) -> bool {
3887    paragraph
3888        .content
3889        .as_ref()
3890        .is_some_and(|nodes| nodes.iter().any(|n| n.node_type == "hardBreak"))
3891}
3892
3893/// Renders a table as GFM pipe syntax.
3894fn render_pipe_table(node: &AdfNode, rows: &[AdfNode], output: &mut String, opts: &RenderOptions) {
3895    for (row_idx, row) in rows.iter().enumerate() {
3896        let Some(ref cells) = row.content else {
3897            continue;
3898        };
3899
3900        output.push('|');
3901        for cell in cells {
3902            output.push(' ');
3903            let mut cell_buf = String::new();
3904            render_cell_attrs_prefix(cell, &mut cell_buf);
3905            render_inline_content_from_first_paragraph(cell, &mut cell_buf, opts);
3906            output.push_str(&escape_pipes_in_cell(&cell_buf));
3907            output.push_str(" |");
3908        }
3909        output.push('\n');
3910
3911        // Add separator after header row
3912        if row_idx == 0 {
3913            output.push('|');
3914            for cell in cells {
3915                let align = get_cell_paragraph_alignment(cell);
3916                match align {
3917                    Some("center") => output.push_str(" :---: |"),
3918                    Some("end") => output.push_str(" ---: |"),
3919                    _ => output.push_str(" --- |"),
3920                }
3921            }
3922            output.push('\n');
3923        }
3924    }
3925
3926    // Emit table-level attrs if present
3927    render_table_level_attrs(node, output, opts);
3928}
3929
3930/// Renders a table as `::::table` directive syntax (block-content cells).
3931fn render_directive_table(
3932    node: &AdfNode,
3933    rows: &[AdfNode],
3934    output: &mut String,
3935    opts: &RenderOptions,
3936) {
3937    // Opening fence with attrs
3938    let mut attr_parts = Vec::new();
3939    if let Some(ref attrs) = node.attrs {
3940        if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3941            attr_parts.push(format!("layout={layout}"));
3942        }
3943        if let Some(numbered) = attrs
3944            .get("isNumberColumnEnabled")
3945            .and_then(serde_json::Value::as_bool)
3946        {
3947            if numbered {
3948                attr_parts.push("numbered".to_string());
3949            } else {
3950                attr_parts.push("numbered=false".to_string());
3951            }
3952        }
3953        if let Some(tw) = attrs.get("width").and_then(serde_json::Value::as_f64) {
3954            let tw_str = if tw.fract() == 0.0 {
3955                (tw as u64).to_string()
3956            } else {
3957                tw.to_string()
3958            };
3959            attr_parts.push(format!("width={tw_str}"));
3960        }
3961        maybe_push_local_id(attrs, &mut attr_parts, opts);
3962    }
3963    if attr_parts.is_empty() {
3964        output.push_str("::::table\n");
3965    } else {
3966        output.push_str(&format!("::::table{{{}}}\n", attr_parts.join(" ")));
3967    }
3968
3969    for row in rows {
3970        if row.node_type == "caption" {
3971            let mut cap_parts = Vec::new();
3972            if let Some(ref attrs) = row.attrs {
3973                maybe_push_local_id(attrs, &mut cap_parts, opts);
3974            }
3975            if cap_parts.is_empty() {
3976                output.push_str(":::caption\n");
3977            } else {
3978                output.push_str(&format!(":::caption{{{}}}\n", cap_parts.join(" ")));
3979            }
3980            if let Some(ref content) = row.content {
3981                for child in content {
3982                    render_inline_node(child, output, opts);
3983                }
3984                output.push('\n');
3985            }
3986            output.push_str(":::\n");
3987            continue;
3988        }
3989        let Some(ref cells) = row.content else {
3990            continue;
3991        };
3992        // Emit :::tr with optional localId
3993        let mut tr_attrs = Vec::new();
3994        if let Some(ref attrs) = row.attrs {
3995            maybe_push_local_id(attrs, &mut tr_attrs, opts);
3996        }
3997        if tr_attrs.is_empty() {
3998            output.push_str(":::tr\n");
3999        } else {
4000            output.push_str(&format!(":::tr{{{}}}\n", tr_attrs.join(" ")));
4001        }
4002        for cell in cells {
4003            let directive_name = if cell.node_type == "tableHeader" {
4004                "th"
4005            } else {
4006                "td"
4007            };
4008            let mut cell_attr_str = build_cell_attrs_string(cell);
4009            // Append localId to cell attrs if present
4010            if let Some(ref attrs) = cell.attrs {
4011                let mut lid_parts = Vec::new();
4012                maybe_push_local_id(attrs, &mut lid_parts, opts);
4013                if !lid_parts.is_empty() {
4014                    if !cell_attr_str.is_empty() {
4015                        cell_attr_str.push(' ');
4016                    }
4017                    cell_attr_str.push_str(&lid_parts.join(" "));
4018                }
4019            }
4020            // Append border mark attrs if present
4021            if let Some(ref marks) = cell.marks {
4022                for mark in marks {
4023                    if mark.mark_type == "border" {
4024                        if let Some(ref attrs) = mark.attrs {
4025                            if let Some(color) =
4026                                attrs.get("color").and_then(serde_json::Value::as_str)
4027                            {
4028                                if !cell_attr_str.is_empty() {
4029                                    cell_attr_str.push(' ');
4030                                }
4031                                cell_attr_str.push_str(&format!("border-color={color}"));
4032                            }
4033                            if let Some(size) =
4034                                attrs.get("size").and_then(serde_json::Value::as_u64)
4035                            {
4036                                if !cell_attr_str.is_empty() {
4037                                    cell_attr_str.push(' ');
4038                                }
4039                                cell_attr_str.push_str(&format!("border-size={size}"));
4040                            }
4041                        }
4042                    }
4043                }
4044            }
4045            let has_marks = cell.marks.as_ref().is_some_and(|m| !m.is_empty());
4046            if cell_attr_str.is_empty() && cell.attrs.is_none() && !has_marks {
4047                output.push_str(&format!(":::{directive_name}\n"));
4048            } else {
4049                output.push_str(&format!(":::{directive_name}{{{cell_attr_str}}}\n"));
4050            }
4051            if let Some(ref content) = cell.content {
4052                render_block_children(content, output, opts);
4053            }
4054            output.push_str(":::\n");
4055        }
4056        output.push_str(":::\n");
4057    }
4058
4059    output.push_str("::::\n");
4060}
4061
4062/// Returns `true` when an attribute value must be quoted to survive round-trip
4063/// through the `{key=value}` attribute parser (which stops unquoted values at
4064/// whitespace or `}`).
4065fn needs_attr_quoting(value: &str) -> bool {
4066    value.contains(|c: char| c.is_whitespace() || c == '}' || c == '(' || c == ')' || c == ',')
4067}
4068
4069/// Builds a JFM attribute string from ADF cell attributes.
4070fn build_cell_attrs_string(cell: &AdfNode) -> String {
4071    let Some(ref attrs) = cell.attrs else {
4072        return String::new();
4073    };
4074    let mut parts = Vec::new();
4075    if let Some(colspan) = attrs.get("colspan").and_then(serde_json::Value::as_u64) {
4076        parts.push(format!("colspan={colspan}"));
4077    }
4078    if let Some(rowspan) = attrs.get("rowspan").and_then(serde_json::Value::as_u64) {
4079        parts.push(format!("rowspan={rowspan}"));
4080    }
4081    if let Some(bg) = attrs.get("background").and_then(serde_json::Value::as_str) {
4082        if needs_attr_quoting(bg) {
4083            let escaped = bg.replace('\\', "\\\\").replace('"', "\\\"");
4084            parts.push(format!("bg=\"{escaped}\""));
4085        } else {
4086            parts.push(format!("bg={bg}"));
4087        }
4088    }
4089    if let Some(colwidth) = attrs.get("colwidth").and_then(serde_json::Value::as_array) {
4090        let widths: Vec<String> = colwidth
4091            .iter()
4092            .filter_map(|v| {
4093                // Preserve the original number type: integers stay as integers,
4094                // floats stay as floats (e.g. Confluence's 254.0 representation).
4095                if let Some(n) = v.as_u64() {
4096                    Some(n.to_string())
4097                } else if let Some(n) = v.as_f64() {
4098                    if n.fract() == 0.0 {
4099                        format!("{n:.1}")
4100                    } else {
4101                        n.to_string()
4102                    }
4103                    .into()
4104                } else {
4105                    None
4106                }
4107            })
4108            .collect();
4109        if !widths.is_empty() {
4110            parts.push(format!("colwidth={}", widths.join(",")));
4111        }
4112    }
4113    parts.join(" ")
4114}
4115
4116/// Renders `{attrs}` prefix for a pipe table cell (background, colspan, etc.).
4117fn render_cell_attrs_prefix(cell: &AdfNode, output: &mut String) {
4118    let Some(ref _attrs) = cell.attrs else {
4119        return;
4120    };
4121    let attr_str = build_cell_attrs_string(cell);
4122    if attr_str.is_empty() {
4123        output.push_str("{} ");
4124    } else {
4125        output.push_str(&format!("{{{attr_str}}} "));
4126    }
4127}
4128
4129/// Gets the alignment mark value from the paragraph inside a table cell.
4130fn get_cell_paragraph_alignment(cell: &AdfNode) -> Option<&str> {
4131    let content = cell.content.as_ref()?;
4132    let para = content.first()?;
4133    let marks = para.marks.as_ref()?;
4134    marks.iter().find_map(|m| {
4135        if m.mark_type == "alignment" {
4136            m.attrs
4137                .as_ref()
4138                .and_then(|a| a.get("align"))
4139                .and_then(serde_json::Value::as_str)
4140        } else {
4141            None
4142        }
4143    })
4144}
4145
4146/// Emits table-level attributes if present.
4147fn render_table_level_attrs(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4148    if let Some(ref attrs) = node.attrs {
4149        let mut parts = Vec::new();
4150        if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
4151            parts.push(format!("layout={layout}"));
4152        }
4153        if let Some(numbered) = attrs
4154            .get("isNumberColumnEnabled")
4155            .and_then(serde_json::Value::as_bool)
4156        {
4157            if numbered {
4158                parts.push("numbered".to_string());
4159            } else {
4160                parts.push("numbered=false".to_string());
4161            }
4162        }
4163        if let Some(tw_str) = attrs.get("width").and_then(fmt_numeric_attr) {
4164            parts.push(format!("width={tw_str}"));
4165        }
4166        maybe_push_local_id(attrs, &mut parts, opts);
4167        if !parts.is_empty() {
4168            output.push_str(&format!("{{{}}}\n", parts.join(" ")));
4169        }
4170    }
4171}
4172
4173/// Renders inline content from the first paragraph child of a table cell.
4174fn render_inline_content_from_first_paragraph(
4175    cell: &AdfNode,
4176    output: &mut String,
4177    opts: &RenderOptions,
4178) {
4179    if let Some(ref content) = cell.content {
4180        if let Some(first) = content.first() {
4181            if first.node_type == "paragraph" {
4182                render_inline_content(first, output, opts);
4183            }
4184        }
4185    }
4186}
4187
4188/// Appends border mark attributes (border-color, border-size) to a parts vec.
4189fn push_border_mark_attrs(marks: &Option<Vec<AdfMark>>, parts: &mut Vec<String>) {
4190    if let Some(ref marks) = marks {
4191        for mark in marks {
4192            if mark.mark_type == "border" {
4193                if let Some(ref attrs) = mark.attrs {
4194                    if let Some(color) = attrs.get("color").and_then(serde_json::Value::as_str) {
4195                        parts.push(format!("border-color={color}"));
4196                    }
4197                    if let Some(size) = attrs.get("size").and_then(serde_json::Value::as_u64) {
4198                        parts.push(format!("border-size={size}"));
4199                    }
4200                }
4201            }
4202        }
4203    }
4204}
4205
4206/// Renders a media node as a markdown image, with optional parent (mediaSingle) attrs.
4207fn render_media(
4208    node: &AdfNode,
4209    parent_attrs: Option<&serde_json::Value>,
4210    output: &mut String,
4211    opts: &RenderOptions,
4212) {
4213    if let Some(ref attrs) = node.attrs {
4214        let media_type = attrs
4215            .get("type")
4216            .and_then(serde_json::Value::as_str)
4217            .unwrap_or("external");
4218        let alt = attrs
4219            .get("alt")
4220            .and_then(serde_json::Value::as_str)
4221            .unwrap_or("");
4222
4223        if media_type == "file" {
4224            // Confluence file attachment — encode metadata in {attrs} block so it survives round-trip
4225            output.push_str(&format!("![{alt}]()"));
4226            let mut parts = vec!["type=file".to_string()];
4227            if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4228                parts.push(format_kv("id", id));
4229            }
4230            if let Some(collection) = attrs.get("collection").and_then(serde_json::Value::as_str) {
4231                parts.push(format_kv("collection", collection));
4232            }
4233            if let Some(occurrence_key) = attrs
4234                .get("occurrenceKey")
4235                .and_then(serde_json::Value::as_str)
4236            {
4237                parts.push(format_kv("occurrenceKey", occurrence_key));
4238            }
4239            if let Some(height_str) = attrs.get("height").and_then(fmt_numeric_attr) {
4240                parts.push(format!("height={height_str}"));
4241            }
4242            if let Some(width_str) = attrs.get("width").and_then(fmt_numeric_attr) {
4243                parts.push(format!("width={width_str}"));
4244            }
4245            maybe_push_local_id(attrs, &mut parts, opts);
4246            // Encode mediaSingle layout/width/widthType if non-default
4247            if let Some(p_attrs) = parent_attrs {
4248                if let Some(layout) = p_attrs.get("layout").and_then(serde_json::Value::as_str) {
4249                    if layout != "center" {
4250                        parts.push(format!("layout={layout}"));
4251                    }
4252                }
4253                if let Some(ms_width_str) = p_attrs.get("width").and_then(fmt_numeric_attr) {
4254                    parts.push(format!("mediaWidth={ms_width_str}"));
4255                }
4256                if let Some(wt) = p_attrs.get("widthType").and_then(serde_json::Value::as_str) {
4257                    parts.push(format!("widthType={wt}"));
4258                }
4259                if let Some(mode) = p_attrs.get("mode").and_then(serde_json::Value::as_str) {
4260                    parts.push(format!("mode={mode}"));
4261                }
4262            }
4263            push_border_mark_attrs(&node.marks, &mut parts);
4264            output.push_str(&format!("{{{}}}", parts.join(" ")));
4265        } else {
4266            // External image
4267            let url = attrs
4268                .get("url")
4269                .and_then(serde_json::Value::as_str)
4270                .unwrap_or("");
4271            output.push_str(&format!("![{alt}]({url})"));
4272
4273            // Emit {layout=... width=... widthType=... mode=... localId=...} if non-default attrs present
4274            {
4275                let mut parts = Vec::new();
4276                if let Some(p_attrs) = parent_attrs {
4277                    let layout = p_attrs.get("layout").and_then(serde_json::Value::as_str);
4278                    let width_str = p_attrs.get("width").and_then(fmt_numeric_attr);
4279                    let width_type = p_attrs.get("widthType").and_then(serde_json::Value::as_str);
4280                    if let Some(l) = layout {
4281                        if l != "center" {
4282                            parts.push(format!("layout={l}"));
4283                        }
4284                    }
4285                    if let Some(w) = width_str {
4286                        parts.push(format!("width={w}"));
4287                    }
4288                    if let Some(wt) = width_type {
4289                        parts.push(format!("widthType={wt}"));
4290                    }
4291                    if let Some(mode) = p_attrs.get("mode").and_then(serde_json::Value::as_str) {
4292                        parts.push(format!("mode={mode}"));
4293                    }
4294                }
4295                maybe_push_local_id(attrs, &mut parts, opts);
4296                push_border_mark_attrs(&node.marks, &mut parts);
4297                if !parts.is_empty() {
4298                    output.push_str(&format!("{{{}}}", parts.join(" ")));
4299                }
4300            }
4301        }
4302
4303        output.push('\n');
4304    }
4305}
4306
4307/// Renders inline content (text nodes with marks) from a block node's children.
4308fn render_inline_content(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4309    if let Some(ref content) = node.content {
4310        for child in content {
4311            render_inline_node(child, output, opts);
4312        }
4313    }
4314}
4315
4316/// Renders a single inline ADF node to markdown.
4317fn render_inline_node(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4318    match node.node_type.as_str() {
4319        "text" => {
4320            let text = node.text.as_deref().unwrap_or("");
4321            let marks = node.marks.as_deref().unwrap_or(&[]);
4322            let has_code = marks.iter().any(|m| m.mark_type == "code");
4323            // Issue #477: Escape literal backslashes before the newline
4324            // encoding below so they are not consumed as JFM escape
4325            // sequences on round-trip.  Code marks emit content verbatim,
4326            // so backslash escaping is skipped for them.
4327            let owned;
4328            let text = if !has_code {
4329                owned = text.replace('\\', "\\\\");
4330                owned.as_str()
4331            } else {
4332                text
4333            };
4334            // Issue #454: A literal newline inside a text node is escaped
4335            // as the two-character sequence `\n` so it survives round-trip
4336            // as a single text node instead of splitting into paragraphs or
4337            // being converted to hardBreak nodes.
4338            let owned_nl;
4339            let text = if text.contains('\n') {
4340                owned_nl = text.replace('\n', "\\n");
4341                owned_nl.as_str()
4342            } else {
4343                text
4344            };
4345            // Issue #510: Two or more trailing spaces at the end of a text
4346            // node would be misinterpreted as a hardBreak marker on
4347            // round-trip (and collapse the following paragraph).  Escape the
4348            // last space with a backslash so the parser treats it as a
4349            // literal space instead of a line-break signal.
4350            let owned_ts;
4351            let text = if !has_code && text.ends_with("  ") {
4352                let mut s = text.to_string();
4353                // Insert backslash before the final space: "foo  " → "foo \ "
4354                s.insert(s.len() - 1, '\\');
4355                owned_ts = s;
4356                owned_ts.as_str()
4357            } else {
4358                text
4359            };
4360            render_marked_text(text, marks, output);
4361        }
4362        "hardBreak" => {
4363            output.push_str("\\\n");
4364        }
4365        other => {
4366            // Issue #471: Non-text inline nodes (emoji, status, date, mention, etc.)
4367            // may carry annotation marks. Render the node body first, then wrap it
4368            // in bracketed-span syntax if annotation marks are present.
4369            let mut body = String::new();
4370            render_non_text_inline_body(other, node, &mut body, opts);
4371
4372            let annotations: Vec<&AdfMark> = node
4373                .marks
4374                .as_deref()
4375                .unwrap_or(&[])
4376                .iter()
4377                .filter(|m| m.mark_type == "annotation")
4378                .collect();
4379
4380            if annotations.is_empty() {
4381                output.push_str(&body);
4382            } else {
4383                let mut attr_parts = Vec::new();
4384                for ann in &annotations {
4385                    if let Some(ref attrs) = ann.attrs {
4386                        if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4387                            let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4388                            attr_parts.push(format!("annotation-id=\"{escaped}\""));
4389                        }
4390                        if let Some(at) = attrs
4391                            .get("annotationType")
4392                            .and_then(serde_json::Value::as_str)
4393                        {
4394                            attr_parts.push(format!("annotation-type={at}"));
4395                        }
4396                    }
4397                }
4398                output.push('[');
4399                output.push_str(&body);
4400                output.push_str("]{");
4401                output.push_str(&attr_parts.join(" "));
4402                output.push('}');
4403            }
4404        }
4405    }
4406}
4407
4408/// Renders the body of a non-text inline node (without mark wrapping).
4409fn render_non_text_inline_body(
4410    node_type: &str,
4411    node: &AdfNode,
4412    output: &mut String,
4413    opts: &RenderOptions,
4414) {
4415    match node_type {
4416        "inlineCard" => {
4417            if let Some(ref attrs) = node.attrs {
4418                if let Some(url) = attrs.get("url").and_then(serde_json::Value::as_str) {
4419                    let mut attr_parts = Vec::new();
4420                    if url_safe_in_bracket_content(url) {
4421                        output.push_str(":card[");
4422                        output.push_str(url);
4423                        output.push(']');
4424                    } else {
4425                        // URL would break `:card[URL]` parsing (e.g. contains an
4426                        // unbalanced `]` or a newline).  Fall back to quoted
4427                        // attribute form so the URL round-trips losslessly.
4428                        output.push_str(":card[]");
4429                        let escaped = url.replace('\\', "\\\\").replace('"', "\\\"");
4430                        attr_parts.push(format!("url=\"{escaped}\""));
4431                    }
4432                    maybe_push_local_id(attrs, &mut attr_parts, opts);
4433                    if !attr_parts.is_empty() {
4434                        output.push('{');
4435                        output.push_str(&attr_parts.join(" "));
4436                        output.push('}');
4437                    }
4438                }
4439            }
4440        }
4441        "emoji" => {
4442            if let Some(ref attrs) = node.attrs {
4443                if let Some(short_name) = attrs.get("shortName").and_then(serde_json::Value::as_str)
4444                {
4445                    output.push(':');
4446                    let name = short_name.strip_prefix(':').unwrap_or(short_name);
4447                    let name = name.strip_suffix(':').unwrap_or(name);
4448                    output.push_str(name);
4449                    output.push(':');
4450
4451                    let mut parts = Vec::new();
4452                    let escaped_sn = short_name.replace('\\', "\\\\").replace('"', "\\\"");
4453                    parts.push(format!("shortName=\"{escaped_sn}\""));
4454                    if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4455                        let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4456                        parts.push(format!("id=\"{escaped}\""));
4457                    }
4458                    if let Some(text) = attrs.get("text").and_then(serde_json::Value::as_str) {
4459                        let escaped = text.replace('\\', "\\\\").replace('"', "\\\"");
4460                        parts.push(format!("text=\"{escaped}\""));
4461                    }
4462                    maybe_push_local_id(attrs, &mut parts, opts);
4463                    output.push('{');
4464                    output.push_str(&parts.join(" "));
4465                    output.push('}');
4466                }
4467            }
4468        }
4469        "status" => {
4470            if let Some(ref attrs) = node.attrs {
4471                let text = attrs
4472                    .get("text")
4473                    .and_then(serde_json::Value::as_str)
4474                    .unwrap_or("");
4475                let color = attrs
4476                    .get("color")
4477                    .and_then(serde_json::Value::as_str)
4478                    .unwrap_or("neutral");
4479                let mut attr_parts = vec![format!("color={color}")];
4480                if let Some(style) = attrs.get("style").and_then(serde_json::Value::as_str) {
4481                    attr_parts.push(format!("style={style}"));
4482                }
4483                maybe_push_local_id(attrs, &mut attr_parts, opts);
4484                output.push_str(&format!(":status[{text}]{{{}}}", attr_parts.join(" ")));
4485            }
4486        }
4487        "date" => {
4488            if let Some(ref attrs) = node.attrs {
4489                if let Some(timestamp) = attrs.get("timestamp").and_then(serde_json::Value::as_str)
4490                {
4491                    let display = epoch_ms_to_iso_date(timestamp);
4492                    let mut attr_parts = vec![format!("timestamp={timestamp}")];
4493                    maybe_push_local_id(attrs, &mut attr_parts, opts);
4494                    output.push_str(&format!(":date[{display}]{{{}}}", attr_parts.join(" ")));
4495                }
4496            }
4497        }
4498        "mention" => {
4499            if let Some(ref attrs) = node.attrs {
4500                let id = attrs
4501                    .get("id")
4502                    .and_then(serde_json::Value::as_str)
4503                    .unwrap_or("");
4504                let text = attrs
4505                    .get("text")
4506                    .and_then(serde_json::Value::as_str)
4507                    .unwrap_or("");
4508                let mut attr_parts = vec![format!("id={id}")];
4509                if let Some(ut) = attrs.get("userType").and_then(serde_json::Value::as_str) {
4510                    attr_parts.push(format!("userType={ut}"));
4511                }
4512                if let Some(al) = attrs.get("accessLevel").and_then(serde_json::Value::as_str) {
4513                    attr_parts.push(format!("accessLevel={al}"));
4514                }
4515                maybe_push_local_id(attrs, &mut attr_parts, opts);
4516                output.push_str(&format!(":mention[{text}]{{{}}}", attr_parts.join(" ")));
4517            }
4518        }
4519        "placeholder" => {
4520            if let Some(ref attrs) = node.attrs {
4521                let text = attrs
4522                    .get("text")
4523                    .and_then(serde_json::Value::as_str)
4524                    .unwrap_or("");
4525                output.push_str(&format!(":placeholder[{text}]"));
4526            }
4527        }
4528        "inlineExtension" => {
4529            if let Some(ref attrs) = node.attrs {
4530                let ext_type = attrs
4531                    .get("extensionType")
4532                    .and_then(serde_json::Value::as_str)
4533                    .unwrap_or("");
4534                let ext_key = attrs
4535                    .get("extensionKey")
4536                    .and_then(serde_json::Value::as_str)
4537                    .unwrap_or("");
4538                let fallback = node.text.as_deref().unwrap_or("");
4539                output.push_str(&format!(
4540                    ":extension[{fallback}]{{type={ext_type} key={ext_key}}}"
4541                ));
4542            }
4543        }
4544        "mediaInline" => {
4545            if let Some(ref attrs) = node.attrs {
4546                let mut attr_parts = Vec::new();
4547                if let Some(media_type) = attrs.get("type").and_then(serde_json::Value::as_str) {
4548                    attr_parts.push(format_kv("type", media_type));
4549                }
4550                if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4551                    attr_parts.push(format_kv("id", id));
4552                }
4553                if let Some(collection) =
4554                    attrs.get("collection").and_then(serde_json::Value::as_str)
4555                {
4556                    attr_parts.push(format_kv("collection", collection));
4557                }
4558                if let Some(url) = attrs.get("url").and_then(serde_json::Value::as_str) {
4559                    attr_parts.push(format_kv("url", url));
4560                }
4561                if let Some(alt) = attrs.get("alt").and_then(serde_json::Value::as_str) {
4562                    attr_parts.push(format_kv("alt", alt));
4563                }
4564                if let Some(width) = attrs.get("width").and_then(serde_json::Value::as_u64) {
4565                    attr_parts.push(format!("width={width}"));
4566                }
4567                if let Some(height) = attrs.get("height").and_then(serde_json::Value::as_u64) {
4568                    attr_parts.push(format!("height={height}"));
4569                }
4570                maybe_push_local_id(attrs, &mut attr_parts, opts);
4571                output.push_str(&format!(":media-inline[]{{{}}}", attr_parts.join(" ")));
4572            }
4573        }
4574        _ => {
4575            output.push_str(&format!("<!-- unsupported inline: {} -->", node.node_type));
4576        }
4577    }
4578}
4579
4580/// Renders text with ADF marks applied as markdown syntax.
4581///
4582/// Mark ordering is preserved by walking the marks array in order and emitting
4583/// one wrapper per mark (outermost first, innermost last).  The resulting
4584/// markdown round-trips back to the original mark sequence because the parser
4585/// reconstructs marks outside-in from the nested delimiter structure.
4586///
4587/// When both `strong` and `em` are present, em is rendered with `_` instead of
4588/// `*` to avoid ambiguity (e.g., `_**text**_` rather than `***text***`).  The
4589/// single exception is `[strong, em]` (exactly those two marks in that order),
4590/// which is rendered as `***text***` to preserve the familiar compact form;
4591/// the parser's triple-delimiter rule round-trips it back to `[strong, em]`.
4592fn render_marked_text(text: &str, marks: &[AdfMark], output: &mut String) {
4593    if marks.iter().any(|m| m.mark_type == "code") {
4594        render_code_marked_text(text, marks, output);
4595        return;
4596    }
4597
4598    let has_link = marks.iter().any(|m| m.mark_type == "link");
4599    let has_strong = marks.iter().any(|m| m.mark_type == "strong");
4600    let has_em = marks.iter().any(|m| m.mark_type == "em");
4601
4602    // Compact form for the common [strong, em] case: ***text***.  em is
4603    // rendered with `*` here (as part of the `***` triple delimiter), so
4604    // underscores in the content don't need escaping.
4605    if marks.len() == 2 && marks[0].mark_type == "strong" && marks[1].mark_type == "em" {
4606        let escaped = escape_emphasis_markers(text);
4607        let escaped = escape_emoji_shortcodes(&escaped);
4608        let escaped = escape_backticks(&escaped);
4609        let escaped = escape_bare_urls(&escaped);
4610        output.push_str("***");
4611        output.push_str(&escaped);
4612        output.push_str("***");
4613        return;
4614    }
4615
4616    // When both strong and em are present (in any order), em uses `_` instead
4617    // of `*` to avoid the `***` triple-delimiter ambiguity.  Otherwise em uses
4618    // `*`, which sidesteps intraword-underscore pitfalls for plain em text.
4619    let em_delim = if has_strong && has_em { "_" } else { "*" };
4620
4621    // Text must also escape `_` when em renders as `_..._` — otherwise any
4622    // underscore in the content would close the emphasis span early.
4623    let escaped = if em_delim == "_" {
4624        escape_emphasis_markers_with_underscore(text)
4625    } else {
4626        escape_emphasis_markers(text)
4627    };
4628    let escaped = escape_emoji_shortcodes(&escaped);
4629    let escaped = escape_backticks(&escaped);
4630    // Always escape bare URLs so they are not re-parsed as `inlineCard`
4631    // nodes on round-trip.  When the text carries a link mark, also escape
4632    // `[` and `]` so they do not terminate the enclosing `[…]` link syntax
4633    // (issue #493).  Escaping bare URLs inside link text additionally
4634    // prevents `\[`/`\]` escapes from leaking through the URL-as-link-text
4635    // fast path and from corrupting an auto-detected bare URL inside the
4636    // link display text (issue #551).
4637    let escaped = escape_bare_urls(&escaped);
4638    let escaped = if has_link {
4639        escape_link_brackets(&escaped)
4640    } else {
4641        escaped
4642    };
4643
4644    // Collect (open, close) wrappers in mark order, outermost first.  Consecutive
4645    // span-attr or bracketed-span marks that happen to be in the parser's
4646    // canonical order (so the merged wrapper parses back to the same mark
4647    // sequence) are merged into a single wrapper; otherwise each mark gets its
4648    // own nested wrapper so that the mark ordering survives the round-trip.
4649    let mut wrappers: Vec<(String, String)> = Vec::new();
4650    let mut i = 0;
4651    while i < marks.len() {
4652        match marks[i].mark_type.as_str() {
4653            "em" => {
4654                wrappers.push((em_delim.to_string(), em_delim.to_string()));
4655                i += 1;
4656            }
4657            "strong" => {
4658                wrappers.push(("**".to_string(), "**".to_string()));
4659                i += 1;
4660            }
4661            "strike" => {
4662                wrappers.push(("~~".to_string(), "~~".to_string()));
4663                i += 1;
4664            }
4665            "link" => {
4666                let href = link_href(&marks[i]);
4667                wrappers.push(("[".to_string(), format!("]({href})")));
4668                i += 1;
4669            }
4670            "textColor" | "backgroundColor" | "subsup" => {
4671                let start = i;
4672                while i < marks.len() && is_span_attr_mark(&marks[i].mark_type) {
4673                    i += 1;
4674                }
4675                emit_span_attr_wrappers(&marks[start..i], &mut wrappers);
4676            }
4677            "underline" | "annotation" => {
4678                let start = i;
4679                while i < marks.len() && is_bracketed_span_mark(&marks[i].mark_type) {
4680                    i += 1;
4681                }
4682                emit_bracketed_wrappers(&marks[start..i], &mut wrappers);
4683            }
4684            _ => {
4685                i += 1;
4686            }
4687        }
4688    }
4689
4690    // Apply wrappers from innermost (last) to outermost (first).
4691    let mut result = escaped;
4692    for (open, close) in wrappers.iter().rev() {
4693        result.insert_str(0, open);
4694        result.push_str(close);
4695    }
4696    output.push_str(&result);
4697}
4698
4699/// Renders a text node with a `code` mark.  Code content is emitted verbatim
4700/// inside backticks, optionally wrapped by a link and/or by `:span`/bracketed-
4701/// span carrying span-attr (`textColor`, `backgroundColor`, `subsup`) and
4702/// bracketed-span (`underline`, `annotation`) marks.  No `em`/`strong`/`strike`
4703/// formatting is applied because markdown code spans do not support nested
4704/// emphasis (issue #554: previously textColor/bg/subsup/underline were
4705/// silently dropped when combined with a code mark).
4706fn render_code_marked_text(text: &str, marks: &[AdfMark], output: &mut String) {
4707    let link_mark = marks.iter().find(|m| m.mark_type == "link");
4708
4709    let mut code_str = String::new();
4710    if let Some(link_mark) = link_mark {
4711        let href = link_href(link_mark);
4712        code_str.push('[');
4713        render_inline_code(text, &mut code_str);
4714        code_str.push_str("](");
4715        code_str.push_str(href);
4716        code_str.push(')');
4717    } else {
4718        render_inline_code(text, &mut code_str);
4719    }
4720
4721    // Build wrappers (outermost first) for span-attr and bracketed-span runs,
4722    // walking marks in order so the round-trip preserves mark ordering.
4723    let mut wrappers: Vec<(String, String)> = Vec::new();
4724    let mut i = 0;
4725    while i < marks.len() {
4726        match marks[i].mark_type.as_str() {
4727            "textColor" | "backgroundColor" | "subsup" => {
4728                let start = i;
4729                while i < marks.len() && is_span_attr_mark(&marks[i].mark_type) {
4730                    i += 1;
4731                }
4732                emit_span_attr_wrappers(&marks[start..i], &mut wrappers);
4733            }
4734            "underline" | "annotation" => {
4735                let start = i;
4736                while i < marks.len() && is_bracketed_span_mark(&marks[i].mark_type) {
4737                    i += 1;
4738                }
4739                emit_bracketed_wrappers(&marks[start..i], &mut wrappers);
4740            }
4741            _ => {
4742                i += 1;
4743            }
4744        }
4745    }
4746
4747    // Apply wrappers from innermost (last) to outermost (first).
4748    let mut result = code_str;
4749    for (open, close) in wrappers.iter().rev() {
4750        result.insert_str(0, open);
4751        result.push_str(close);
4752    }
4753    output.push_str(&result);
4754}
4755
4756/// Collects `:span` attribute fragments (color, bg, sub/sup) for a single mark.
4757fn collect_span_attr(mark: &AdfMark, attrs: &mut Vec<String>) {
4758    match mark.mark_type.as_str() {
4759        "textColor" => {
4760            if let Some(c) = mark
4761                .attrs
4762                .as_ref()
4763                .and_then(|a| a.get("color"))
4764                .and_then(serde_json::Value::as_str)
4765            {
4766                attrs.push(format!("color={c}"));
4767            }
4768        }
4769        "backgroundColor" => {
4770            if let Some(c) = mark
4771                .attrs
4772                .as_ref()
4773                .and_then(|a| a.get("color"))
4774                .and_then(serde_json::Value::as_str)
4775            {
4776                attrs.push(format!("bg={c}"));
4777            }
4778        }
4779        "subsup" => {
4780            if let Some(kind) = mark
4781                .attrs
4782                .as_ref()
4783                .and_then(|a| a.get("type"))
4784                .and_then(serde_json::Value::as_str)
4785            {
4786                attrs.push(kind.to_string());
4787            }
4788        }
4789        _ => {}
4790    }
4791}
4792
4793/// Collects bracketed-span attribute fragments for an `underline` or `annotation` mark.
4794fn collect_bracketed_attr(mark: &AdfMark, attrs: &mut Vec<String>) {
4795    match mark.mark_type.as_str() {
4796        "underline" => attrs.push("underline".to_string()),
4797        "annotation" => {
4798            if let Some(ref a) = mark.attrs {
4799                if let Some(id) = a.get("id").and_then(serde_json::Value::as_str) {
4800                    let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4801                    attrs.push(format!("annotation-id=\"{escaped}\""));
4802                }
4803                if let Some(at) = a.get("annotationType").and_then(serde_json::Value::as_str) {
4804                    attrs.push(format!("annotation-type={at}"));
4805                }
4806            }
4807        }
4808        _ => {}
4809    }
4810}
4811
4812fn is_span_attr_mark(mark_type: &str) -> bool {
4813    matches!(mark_type, "textColor" | "backgroundColor" | "subsup")
4814}
4815
4816fn is_bracketed_span_mark(mark_type: &str) -> bool {
4817    matches!(mark_type, "underline" | "annotation")
4818}
4819
4820/// Canonical ordering for span-attr marks, matching the order in which the
4821/// `:span` directive parser reads attributes (`color`, then `bg`, then
4822/// `sub`/`sup`).
4823fn span_attr_order(mark_type: &str) -> u8 {
4824    match mark_type {
4825        "textColor" => 0,
4826        "backgroundColor" => 1,
4827        "subsup" => 2,
4828        _ => u8::MAX,
4829    }
4830}
4831
4832/// Returns `true` if the run of span-attr marks is in the canonical order the
4833/// `:span` parser would produce.  A canonical run can be merged into one
4834/// `:span[...]{...}` wrapper; a non-canonical run must be split into one
4835/// nested wrapper per mark so the ordering survives the round-trip.
4836fn span_run_is_canonical(run: &[AdfMark]) -> bool {
4837    let mut prev = 0;
4838    for m in run {
4839        let order = span_attr_order(&m.mark_type);
4840        if order == u8::MAX || order < prev {
4841            return false;
4842        }
4843        prev = order;
4844    }
4845    true
4846}
4847
4848/// Returns `true` if the run of `underline`/`annotation` marks is in the
4849/// canonical order the bracketed-span parser produces (`underline` first,
4850/// followed by annotations).  A canonical run can be merged into one
4851/// `[...]{underline annotation-id=...}` wrapper.
4852fn bracketed_run_is_canonical(run: &[AdfMark]) -> bool {
4853    let mut seen_annotation = false;
4854    for m in run {
4855        match m.mark_type.as_str() {
4856            "underline" => {
4857                if seen_annotation {
4858                    return false;
4859                }
4860            }
4861            "annotation" => seen_annotation = true,
4862            _ => return false,
4863        }
4864    }
4865    true
4866}
4867
4868/// Emits one or more `:span[...]{...}` wrappers for a run of span-attr marks.
4869/// Canonical-order runs collapse into a single wrapper; non-canonical runs
4870/// emit one wrapper per mark so the order round-trips.
4871fn emit_span_attr_wrappers(run: &[AdfMark], wrappers: &mut Vec<(String, String)>) {
4872    if span_run_is_canonical(run) {
4873        let mut attrs = Vec::new();
4874        for m in run {
4875            collect_span_attr(m, &mut attrs);
4876        }
4877        wrappers.push((":span[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4878        return;
4879    }
4880    for m in run {
4881        let mut attrs = Vec::new();
4882        collect_span_attr(m, &mut attrs);
4883        wrappers.push((":span[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4884    }
4885}
4886
4887/// Emits one or more `[...]{...}` wrappers for a run of `underline`/`annotation`
4888/// marks.  Canonical-order runs collapse into a single wrapper; non-canonical
4889/// runs emit one wrapper per mark so the order round-trips.
4890fn emit_bracketed_wrappers(run: &[AdfMark], wrappers: &mut Vec<(String, String)>) {
4891    if bracketed_run_is_canonical(run) {
4892        let mut attrs = Vec::new();
4893        for m in run {
4894            collect_bracketed_attr(m, &mut attrs);
4895        }
4896        wrappers.push(("[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4897        return;
4898    }
4899    for m in run {
4900        let mut attrs = Vec::new();
4901        collect_bracketed_attr(m, &mut attrs);
4902        wrappers.push(("[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4903    }
4904}
4905
4906/// Extracts the href from a link mark.
4907fn link_href(mark: &AdfMark) -> &str {
4908    mark.attrs
4909        .as_ref()
4910        .and_then(|a| a.get("href"))
4911        .and_then(serde_json::Value::as_str)
4912        .unwrap_or("")
4913}
4914
4915#[cfg(test)]
4916#[allow(
4917    clippy::unwrap_used,
4918    clippy::expect_used,
4919    clippy::needless_update,
4920    clippy::needless_collect,
4921    duplicate_macro_attributes
4922)]
4923mod tests {
4924    use super::*;
4925
4926    // ── adf_to_plain_text tests ─────────────────────────────────────
4927
4928    #[test]
4929    fn adf_to_plain_text_single_paragraph() {
4930        let doc = markdown_to_adf("Hello world").unwrap();
4931        assert_eq!(adf_to_plain_text(&doc), "Hello world");
4932    }
4933
4934    #[test]
4935    fn adf_to_plain_text_multiple_paragraphs_space_separated() {
4936        let doc = markdown_to_adf("Alpha\n\nBeta").unwrap();
4937        let plain = adf_to_plain_text(&doc);
4938        // Blocks are space-separated so multi-paragraph anchor selections match.
4939        assert!(plain.contains("Alpha"));
4940        assert!(plain.contains("Beta"));
4941        assert_eq!(plain, "Alpha Beta");
4942    }
4943
4944    #[test]
4945    fn adf_to_plain_text_drops_marks_but_keeps_text() {
4946        let doc = markdown_to_adf("Hello **bold** world").unwrap();
4947        assert_eq!(adf_to_plain_text(&doc), "Hello bold world");
4948    }
4949
4950    #[test]
4951    fn adf_to_plain_text_empty_doc() {
4952        let doc = AdfDocument::new();
4953        assert_eq!(adf_to_plain_text(&doc), "");
4954    }
4955
4956    #[test]
4957    fn adf_to_plain_text_leading_empty_block_emits_no_extra_space() {
4958        // An empty paragraph followed by a text-bearing one must not produce
4959        // a leading space — the separator logic skips when `out` is still empty.
4960        let doc = AdfDocument {
4961            version: 1,
4962            doc_type: "doc".to_string(),
4963            content: vec![
4964                AdfNode {
4965                    node_type: "paragraph".to_string(),
4966                    attrs: None,
4967                    content: Some(vec![]),
4968                    text: None,
4969                    marks: None,
4970                    local_id: None,
4971                    parameters: None,
4972                },
4973                AdfNode {
4974                    node_type: "paragraph".to_string(),
4975                    attrs: None,
4976                    content: Some(vec![AdfNode::text("Hello")]),
4977                    text: None,
4978                    marks: None,
4979                    local_id: None,
4980                    parameters: None,
4981                },
4982            ],
4983        };
4984        assert_eq!(adf_to_plain_text(&doc), "Hello");
4985    }
4986
4987    // ── markdown_to_adf tests ───────────────────────────────────────
4988
4989    #[test]
4990    fn paragraph() {
4991        let doc = markdown_to_adf("Hello world").unwrap();
4992        assert_eq!(doc.content.len(), 1);
4993        assert_eq!(doc.content[0].node_type, "paragraph");
4994    }
4995
4996    #[test]
4997    fn heading_levels() {
4998        for level in 1..=6 {
4999            let hashes = "#".repeat(level);
5000            let md = format!("{hashes} Title");
5001            let doc = markdown_to_adf(&md).unwrap();
5002            assert_eq!(doc.content[0].node_type, "heading");
5003            let attrs = doc.content[0].attrs.as_ref().unwrap();
5004            assert_eq!(attrs["level"], level as u64);
5005        }
5006    }
5007
5008    // ── issue #1005: inline `code` is not permitted on headings ─────
5009
5010    #[test]
5011    fn heading_inline_code_mark_stripped() {
5012        // `### `GET /api`` parses an inline-code span; ADF forbids the `code`
5013        // mark on headings, so it is stripped and the text kept as plain.
5014        let doc = markdown_to_adf("### `GET /api`").unwrap();
5015        let heading = &doc.content[0];
5016        assert_eq!(heading.node_type, "heading");
5017        let content = heading.content.as_ref().unwrap();
5018        assert_eq!(content.len(), 1);
5019        assert_eq!(content[0].text.as_deref(), Some("GET /api"));
5020        assert!(
5021            content[0].marks.is_none(),
5022            "expected no marks, got: {:?}",
5023            content[0].marks
5024        );
5025    }
5026
5027    #[test]
5028    fn heading_code_strip_preserves_sibling_strong() {
5029        // `### **`x`**` → text `x` carrying `strong`; only `code` is removed.
5030        let doc = markdown_to_adf("### **`x`**").unwrap();
5031        let content = doc.content[0].content.as_ref().unwrap();
5032        let marks = content[0].marks.as_ref().unwrap();
5033        assert!(marks.iter().any(|m| m.mark_type == "strong"));
5034        assert!(!marks.iter().any(|m| m.mark_type == "code"));
5035    }
5036
5037    #[test]
5038    fn heading_code_strip_preserves_link() {
5039        // `### [`x`](url)` → text `x` carrying `link`; only `code` is removed.
5040        let doc = markdown_to_adf("### [`x`](https://e.com)").unwrap();
5041        let content = doc.content[0].content.as_ref().unwrap();
5042        let marks = content[0].marks.as_ref().unwrap();
5043        assert!(marks.iter().any(|m| m.mark_type == "link"));
5044        assert!(!marks.iter().any(|m| m.mark_type == "code"));
5045    }
5046
5047    #[test]
5048    fn heading_with_code_now_passes_validation() {
5049        // End-to-end guard: the converted document no longer trips the ADF
5050        // mark validator that previously rejected it at write time.
5051        let doc = markdown_to_adf("### `GET /api`").unwrap();
5052        let violations = crate::atlassian::adf_schema::validate_document(&doc);
5053        assert!(violations.is_empty(), "got: {violations:?}");
5054    }
5055
5056    #[test]
5057    fn paragraph_inline_code_mark_preserved() {
5058        // Regression guard: the strip is heading-only — paragraph inline code
5059        // keeps its `code` mark.
5060        let doc = markdown_to_adf("`x`").unwrap();
5061        let content = doc.content[0].content.as_ref().unwrap();
5062        let marks = content[0].marks.as_ref().unwrap();
5063        assert!(marks.iter().any(|m| m.mark_type == "code"));
5064    }
5065
5066    #[test]
5067    fn code_block() {
5068        let md = "```rust\nfn main() {}\n```";
5069        let doc = markdown_to_adf(md).unwrap();
5070        assert_eq!(doc.content[0].node_type, "codeBlock");
5071        let attrs = doc.content[0].attrs.as_ref().unwrap();
5072        assert_eq!(attrs["language"], "rust");
5073    }
5074
5075    #[test]
5076    fn code_block_no_language() {
5077        let md = "```\nsome code\n```";
5078        let doc = markdown_to_adf(md).unwrap();
5079        assert_eq!(doc.content[0].node_type, "codeBlock");
5080        assert!(doc.content[0].attrs.is_none());
5081    }
5082
5083    #[test]
5084    fn code_block_empty_language() {
5085        let md = "```\"\"\nsome code\n```";
5086        let doc = markdown_to_adf(md).unwrap();
5087        assert_eq!(doc.content[0].node_type, "codeBlock");
5088        let attrs = doc.content[0].attrs.as_ref().unwrap();
5089        assert_eq!(attrs["language"], "");
5090    }
5091
5092    #[test]
5093    fn horizontal_rule() {
5094        let doc = markdown_to_adf("---").unwrap();
5095        assert_eq!(doc.content[0].node_type, "rule");
5096    }
5097
5098    #[test]
5099    fn horizontal_rule_stars() {
5100        let doc = markdown_to_adf("***").unwrap();
5101        assert_eq!(doc.content[0].node_type, "rule");
5102    }
5103
5104    #[test]
5105    fn blockquote() {
5106        let md = "> This is a quote\n> Second line";
5107        let doc = markdown_to_adf(md).unwrap();
5108        assert_eq!(doc.content[0].node_type, "blockquote");
5109    }
5110
5111    #[test]
5112    fn bullet_list() {
5113        let md = "- Item 1\n- Item 2\n- Item 3";
5114        let doc = markdown_to_adf(md).unwrap();
5115        assert_eq!(doc.content[0].node_type, "bulletList");
5116        let items = doc.content[0].content.as_ref().unwrap();
5117        assert_eq!(items.len(), 3);
5118    }
5119
5120    #[test]
5121    fn ordered_list() {
5122        let md = "1. First\n2. Second\n3. Third";
5123        let doc = markdown_to_adf(md).unwrap();
5124        assert_eq!(doc.content[0].node_type, "orderedList");
5125        let items = doc.content[0].content.as_ref().unwrap();
5126        assert_eq!(items.len(), 3);
5127    }
5128
5129    #[test]
5130    fn task_list() {
5131        let md = "- [ ] Todo item\n- [x] Done item";
5132        let doc = markdown_to_adf(md).unwrap();
5133        assert_eq!(doc.content[0].node_type, "taskList");
5134        let items = doc.content[0].content.as_ref().unwrap();
5135        assert_eq!(items.len(), 2);
5136        assert_eq!(items[0].node_type, "taskItem");
5137        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5138        assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5139    }
5140
5141    #[test]
5142    fn task_list_uppercase_x() {
5143        let md = "- [X] Done item";
5144        let doc = markdown_to_adf(md).unwrap();
5145        assert_eq!(doc.content[0].node_type, "taskList");
5146        let item = &doc.content[0].content.as_ref().unwrap()[0];
5147        assert_eq!(item.attrs.as_ref().unwrap()["state"], "DONE");
5148    }
5149
5150    /// Issue #548: an empty task marker (no trailing space) must still be
5151    /// parsed as a `taskList` rather than a `bulletList` with `[ ]` text.
5152    #[test]
5153    fn task_list_empty_todo_no_trailing_space() {
5154        let md = "- [ ]";
5155        let doc = markdown_to_adf(md).unwrap();
5156        assert_eq!(doc.content[0].node_type, "taskList");
5157        let items = doc.content[0].content.as_ref().unwrap();
5158        assert_eq!(items.len(), 1);
5159        assert_eq!(items[0].node_type, "taskItem");
5160        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5161        assert!(items[0].content.is_none());
5162    }
5163
5164    /// Issue #548: likewise for a done checkbox with no body.
5165    #[test]
5166    fn task_list_empty_done_no_trailing_space() {
5167        let md = "- [x]\n- [X]";
5168        let doc = markdown_to_adf(md).unwrap();
5169        assert_eq!(doc.content[0].node_type, "taskList");
5170        let items = doc.content[0].content.as_ref().unwrap();
5171        assert_eq!(items.len(), 2);
5172        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DONE");
5173        assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5174    }
5175
5176    /// Issue #548: the body of `- [ ] text` must not have a spurious leading
5177    /// space introduced by relaxing the trailing-space requirement.
5178    #[test]
5179    fn task_list_body_has_no_leading_space() {
5180        let md = "- [ ] Buy groceries";
5181        let doc = markdown_to_adf(md).unwrap();
5182        let item = &doc.content[0].content.as_ref().unwrap()[0];
5183        let text = item.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5184        assert_eq!(text, "Buy groceries");
5185    }
5186
5187    /// Issue #548: round-trip from ADF with empty taskItems should preserve
5188    /// the `taskList` structure even if trailing spaces are stripped from the
5189    /// intermediate markdown (as many editors do).
5190    #[test]
5191    fn round_trip_empty_task_items_stripped_trailing_spaces() {
5192        let json = r#"{
5193            "version": 1,
5194            "type": "doc",
5195            "content": [{
5196                "type": "taskList",
5197                "attrs": {"localId": "abc"},
5198                "content": [
5199                    {"type": "taskItem", "attrs": {"localId": "def", "state": "TODO"}},
5200                    {"type": "taskItem", "attrs": {"localId": "ghi", "state": "DONE"}}
5201                ]
5202            }]
5203        }"#;
5204        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5205        let md = adf_to_markdown(&doc).unwrap();
5206        let stripped: String = md.lines().map(str::trim_end).collect::<Vec<_>>().join("\n");
5207        let parsed = markdown_to_adf(&stripped).unwrap();
5208        assert_eq!(parsed.content[0].node_type, "taskList");
5209        let items = parsed.content[0].content.as_ref().unwrap();
5210        assert_eq!(items.len(), 2);
5211        assert_eq!(items[0].node_type, "taskItem");
5212        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5213        assert_eq!(items[1].node_type, "taskItem");
5214        assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5215    }
5216
5217    #[test]
5218    fn try_parse_task_marker_accepts_bare_checkbox() {
5219        assert_eq!(try_parse_task_marker("[ ]"), Some(("TODO", "")));
5220        assert_eq!(try_parse_task_marker("[x]"), Some(("DONE", "")));
5221        assert_eq!(try_parse_task_marker("[X]"), Some(("DONE", "")));
5222        assert_eq!(try_parse_task_marker("[ ] foo"), Some(("TODO", "foo")));
5223        assert_eq!(try_parse_task_marker("[x] foo"), Some(("DONE", "foo")));
5224        assert_eq!(try_parse_task_marker("[ ]foo"), None);
5225        assert_eq!(try_parse_task_marker("[x]foo"), None);
5226        assert_eq!(try_parse_task_marker("[y] foo"), None);
5227    }
5228
5229    #[test]
5230    fn starts_with_task_marker_matches_parser() {
5231        // Anything `try_parse_task_marker` recognises must also be flagged
5232        // here so the renderer escapes it.
5233        assert!(starts_with_task_marker("[ ]"));
5234        assert!(starts_with_task_marker("[x]"));
5235        assert!(starts_with_task_marker("[X]"));
5236        assert!(starts_with_task_marker("[ ] foo"));
5237        assert!(starts_with_task_marker("[x] foo\n"));
5238        assert!(starts_with_task_marker("[ ]\n"));
5239        // No collision when the bracket is followed by non-whitespace.
5240        assert!(!starts_with_task_marker("[ ]foo"));
5241        assert!(!starts_with_task_marker("[y] foo"));
5242        assert!(!starts_with_task_marker("foo [ ] bar"));
5243        assert!(!starts_with_task_marker(""));
5244    }
5245
5246    /// Issue #548: a `bulletList` whose item starts with literal `[ ]` text
5247    /// must round-trip through markdown without being promoted to a
5248    /// `taskList`.
5249    #[test]
5250    fn round_trip_bullet_list_with_literal_checkbox_text() {
5251        let json = r#"{
5252            "version": 1,
5253            "type": "doc",
5254            "content": [{
5255                "type": "bulletList",
5256                "content": [{
5257                    "type": "listItem",
5258                    "content": [{
5259                        "type": "paragraph",
5260                        "content": [
5261                            {"type": "text", "text": "[ ] Review the "},
5262                            {"type": "text", "text": "config.yaml", "marks": [{"type": "code"}]},
5263                            {"type": "text", "text": " file"}
5264                        ]
5265                    }]
5266                }]
5267            }]
5268        }"#;
5269        let original: AdfDocument = serde_json::from_str(json).unwrap();
5270        let md = adf_to_markdown(&original).unwrap();
5271        // Renderer must escape the leading bracket.
5272        assert!(
5273            md.contains(r"- \[ ] Review the "),
5274            "rendered markdown: {md:?}"
5275        );
5276        let parsed = markdown_to_adf(&md).unwrap();
5277        assert_eq!(parsed.content[0].node_type, "bulletList");
5278        let item = &parsed.content[0].content.as_ref().unwrap()[0];
5279        assert_eq!(item.node_type, "listItem");
5280        let para = &item.content.as_ref().unwrap()[0];
5281        assert_eq!(para.node_type, "paragraph");
5282        let text_nodes = para.content.as_ref().unwrap();
5283        assert_eq!(text_nodes[0].text.as_deref().unwrap(), "[ ] Review the ");
5284        assert_eq!(text_nodes[1].text.as_deref().unwrap(), "config.yaml");
5285        assert_eq!(text_nodes[2].text.as_deref().unwrap(), " file");
5286    }
5287
5288    /// Issue #548: the same problem with a `[x]` marker.
5289    #[test]
5290    fn round_trip_bullet_list_with_literal_done_checkbox_text() {
5291        let json = r#"{
5292            "version": 1,
5293            "type": "doc",
5294            "content": [{
5295                "type": "bulletList",
5296                "content": [{
5297                    "type": "listItem",
5298                    "content": [{
5299                        "type": "paragraph",
5300                        "content": [{"type": "text", "text": "[x] not actually done"}]
5301                    }]
5302                }]
5303            }]
5304        }"#;
5305        let original: AdfDocument = serde_json::from_str(json).unwrap();
5306        let md = adf_to_markdown(&original).unwrap();
5307        assert!(md.contains(r"- \[x] "), "rendered markdown: {md:?}");
5308        let parsed = markdown_to_adf(&md).unwrap();
5309        assert_eq!(parsed.content[0].node_type, "bulletList");
5310        let item = &parsed.content[0].content.as_ref().unwrap()[0];
5311        let para = &item.content.as_ref().unwrap()[0];
5312        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5313        assert_eq!(text, "[x] not actually done");
5314    }
5315
5316    /// Issue #548: `bulletList` item whose entire content is literal `[ ]`.
5317    #[test]
5318    fn round_trip_bullet_list_with_bare_literal_checkbox() {
5319        let json = r#"{
5320            "version": 1,
5321            "type": "doc",
5322            "content": [{
5323                "type": "bulletList",
5324                "content": [{
5325                    "type": "listItem",
5326                    "content": [{
5327                        "type": "paragraph",
5328                        "content": [{"type": "text", "text": "[ ]"}]
5329                    }]
5330                }]
5331            }]
5332        }"#;
5333        let original: AdfDocument = serde_json::from_str(json).unwrap();
5334        let md = adf_to_markdown(&original).unwrap();
5335        let parsed = markdown_to_adf(&md).unwrap();
5336        assert_eq!(parsed.content[0].node_type, "bulletList");
5337        let item = &parsed.content[0].content.as_ref().unwrap()[0];
5338        let para = &item.content.as_ref().unwrap()[0];
5339        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5340        assert_eq!(text, "[ ]");
5341    }
5342
5343    /// Issue #548: a `bulletList` with a non-task `[?]` prefix should not be
5344    /// escaped — that would just produce noise.
5345    #[test]
5346    fn bullet_list_non_task_bracket_text_not_escaped() {
5347        let json = r#"{
5348            "version": 1,
5349            "type": "doc",
5350            "content": [{
5351                "type": "bulletList",
5352                "content": [{
5353                    "type": "listItem",
5354                    "content": [{
5355                        "type": "paragraph",
5356                        "content": [{"type": "text", "text": "[?] unsure"}]
5357                    }]
5358                }]
5359            }]
5360        }"#;
5361        let original: AdfDocument = serde_json::from_str(json).unwrap();
5362        let md = adf_to_markdown(&original).unwrap();
5363        assert!(!md.contains(r"\["), "should not escape: {md:?}");
5364        assert!(md.contains("- [?] unsure"), "rendered: {md:?}");
5365    }
5366
5367    /// Issue #548: nested `bulletList` items inside another `bulletList`
5368    /// must also have their literal `[ ]` text escaped.
5369    #[test]
5370    fn round_trip_nested_bullet_list_with_literal_checkbox_text() {
5371        let json = r#"{
5372            "version": 1,
5373            "type": "doc",
5374            "content": [{
5375                "type": "bulletList",
5376                "content": [{
5377                    "type": "listItem",
5378                    "content": [
5379                        {"type": "paragraph", "content": [{"type": "text", "text": "outer"}]},
5380                        {"type": "bulletList", "content": [{
5381                            "type": "listItem",
5382                            "content": [{
5383                                "type": "paragraph",
5384                                "content": [{"type": "text", "text": "[ ] inner literal"}]
5385                            }]
5386                        }]}
5387                    ]
5388                }]
5389            }]
5390        }"#;
5391        let original: AdfDocument = serde_json::from_str(json).unwrap();
5392        let md = adf_to_markdown(&original).unwrap();
5393        let parsed = markdown_to_adf(&md).unwrap();
5394        let outer = &parsed.content[0];
5395        assert_eq!(outer.node_type, "bulletList");
5396        let outer_item = &outer.content.as_ref().unwrap()[0];
5397        let inner_list = &outer_item.content.as_ref().unwrap()[1];
5398        assert_eq!(inner_list.node_type, "bulletList");
5399        let inner_item = &inner_list.content.as_ref().unwrap()[0];
5400        assert_eq!(inner_item.node_type, "listItem");
5401        let para = &inner_item.content.as_ref().unwrap()[0];
5402        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5403        assert_eq!(text, "[ ] inner literal");
5404    }
5405
5406    #[test]
5407    fn adf_task_list_to_markdown() {
5408        let doc = AdfDocument {
5409            version: 1,
5410            doc_type: "doc".to_string(),
5411            content: vec![AdfNode::task_list(vec![
5412                AdfNode::task_item(
5413                    "TODO",
5414                    vec![AdfNode::paragraph(vec![AdfNode::text("Todo")])],
5415                ),
5416                AdfNode::task_item(
5417                    "DONE",
5418                    vec![AdfNode::paragraph(vec![AdfNode::text("Done")])],
5419                ),
5420            ])],
5421        };
5422        let md = adf_to_markdown(&doc).unwrap();
5423        assert!(md.contains("- [ ] Todo"));
5424        assert!(md.contains("- [x] Done"));
5425    }
5426
5427    #[test]
5428    fn round_trip_task_list() {
5429        let md = "- [ ] Todo item\n- [x] Done item\n";
5430        let doc = markdown_to_adf(md).unwrap();
5431        let result = adf_to_markdown(&doc).unwrap();
5432        assert!(result.contains("- [ ] Todo item"));
5433        assert!(result.contains("- [x] Done item"));
5434    }
5435
5436    /// Issue #408: taskItem content with inline nodes directly (no paragraph wrapper).
5437    #[test]
5438    fn adf_task_item_unwrapped_inline_content() {
5439        // Real Confluence ADF: taskItem contains text nodes directly, no paragraph.
5440        let json = r#"{
5441            "version": 1,
5442            "type": "doc",
5443            "content": [{
5444                "type": "taskList",
5445                "attrs": {"localId": "list-001"},
5446                "content": [{
5447                    "type": "taskItem",
5448                    "attrs": {"localId": "task-001", "state": "TODO"},
5449                    "content": [{"type": "text", "text": "Do something"}]
5450                }]
5451            }]
5452        }"#;
5453        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5454        let md = adf_to_markdown(&doc).unwrap();
5455        assert!(md.contains("- [ ] Do something"), "got: {md}");
5456        assert!(!md.contains("adf-unsupported"), "got: {md}");
5457    }
5458
5459    /// Issue #408: multiple taskItems with unwrapped inline content.
5460    #[test]
5461    fn adf_task_list_multiple_unwrapped_items() {
5462        let json = r#"{
5463            "version": 1,
5464            "type": "doc",
5465            "content": [{
5466                "type": "taskList",
5467                "attrs": {"localId": "list-001"},
5468                "content": [
5469                    {
5470                        "type": "taskItem",
5471                        "attrs": {"localId": "task-001", "state": "TODO"},
5472                        "content": [{"type": "text", "text": "First task"}]
5473                    },
5474                    {
5475                        "type": "taskItem",
5476                        "attrs": {"localId": "task-002", "state": "DONE"},
5477                        "content": [{"type": "text", "text": "Second task"}]
5478                    }
5479                ]
5480            }]
5481        }"#;
5482        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5483        let md = adf_to_markdown(&doc).unwrap();
5484        assert!(md.contains("- [ ] First task"), "got: {md}");
5485        assert!(md.contains("- [x] Second task"), "got: {md}");
5486        assert!(!md.contains("adf-unsupported"), "got: {md}");
5487    }
5488
5489    /// Issue #408: unwrapped inline content with marks (bold text).
5490    #[test]
5491    fn adf_task_item_unwrapped_inline_with_marks() {
5492        let json = r#"{
5493            "version": 1,
5494            "type": "doc",
5495            "content": [{
5496                "type": "taskList",
5497                "attrs": {"localId": "list-001"},
5498                "content": [{
5499                    "type": "taskItem",
5500                    "attrs": {"localId": "task-001", "state": "TODO"},
5501                    "content": [
5502                        {"type": "text", "text": "Buy "},
5503                        {"type": "text", "text": "groceries", "marks": [{"type": "strong"}]},
5504                        {"type": "text", "text": " today"}
5505                    ]
5506                }]
5507            }]
5508        }"#;
5509        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5510        let md = adf_to_markdown(&doc).unwrap();
5511        assert!(md.contains("- [ ] Buy **groceries** today"), "got: {md}");
5512    }
5513
5514    /// Issue #408: taskItem localId is preserved for unwrapped inline content.
5515    #[test]
5516    fn adf_task_item_unwrapped_preserves_local_id() {
5517        let json = r#"{
5518            "version": 1,
5519            "type": "doc",
5520            "content": [{
5521                "type": "taskList",
5522                "attrs": {"localId": "list-001"},
5523                "content": [{
5524                    "type": "taskItem",
5525                    "attrs": {"localId": "task-001", "state": "TODO"},
5526                    "content": [{"type": "text", "text": "Do something"}]
5527                }]
5528            }]
5529        }"#;
5530        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5531        let md = adf_to_markdown(&doc).unwrap();
5532        assert!(md.contains("{localId=task-001}"), "got: {md}");
5533        assert!(md.contains("{localId=list-001}"), "got: {md}");
5534    }
5535
5536    /// Issue #408: round-trip from Confluence ADF with unwrapped taskItem content.
5537    #[test]
5538    fn round_trip_task_list_unwrapped_inline() {
5539        let json = r#"{
5540            "version": 1,
5541            "type": "doc",
5542            "content": [{
5543                "type": "taskList",
5544                "attrs": {"localId": "list-001"},
5545                "content": [
5546                    {
5547                        "type": "taskItem",
5548                        "attrs": {"localId": "task-001", "state": "TODO"},
5549                        "content": [{"type": "text", "text": "Do something"}]
5550                    },
5551                    {
5552                        "type": "taskItem",
5553                        "attrs": {"localId": "task-002", "state": "DONE"},
5554                        "content": [{"type": "text", "text": "Already done"}]
5555                    }
5556                ]
5557            }]
5558        }"#;
5559        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5560        let md = adf_to_markdown(&doc).unwrap();
5561
5562        // Round-trip: markdown back to ADF
5563        let doc2 = markdown_to_adf(&md).unwrap();
5564        assert_eq!(doc2.content[0].node_type, "taskList");
5565
5566        let items = doc2.content[0].content.as_ref().unwrap();
5567        assert_eq!(items.len(), 2);
5568        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5569        assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5570
5571        // localIds preserved
5572        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "task-001");
5573        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "task-002");
5574        assert_eq!(
5575            doc2.content[0].attrs.as_ref().unwrap()["localId"],
5576            "list-001"
5577        );
5578    }
5579
5580    /// Issue #408: taskItem with inline content followed by a nested block (sub-list).
5581    #[test]
5582    fn adf_task_item_unwrapped_inline_then_block() {
5583        let json = r#"{
5584            "version": 1,
5585            "type": "doc",
5586            "content": [{
5587                "type": "taskList",
5588                "attrs": {"localId": "list-001"},
5589                "content": [{
5590                    "type": "taskItem",
5591                    "attrs": {"localId": "task-001", "state": "TODO"},
5592                    "content": [
5593                        {"type": "text", "text": "Parent task"},
5594                        {
5595                            "type": "bulletList",
5596                            "content": [{
5597                                "type": "listItem",
5598                                "content": [{
5599                                    "type": "paragraph",
5600                                    "content": [{"type": "text", "text": "sub-item"}]
5601                                }]
5602                            }]
5603                        }
5604                    ]
5605                }]
5606            }]
5607        }"#;
5608        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5609        let md = adf_to_markdown(&doc).unwrap();
5610        assert!(md.contains("- [ ] Parent task"), "got: {md}");
5611        assert!(md.contains("  - sub-item"), "got: {md}");
5612        assert!(!md.contains("adf-unsupported"), "got: {md}");
5613    }
5614
5615    /// Issue #408: taskItem with empty content array renders without panic.
5616    #[test]
5617    fn adf_task_item_empty_content() {
5618        let json = r#"{
5619            "version": 1,
5620            "type": "doc",
5621            "content": [{
5622                "type": "taskList",
5623                "attrs": {"localId": "list-001"},
5624                "content": [{
5625                    "type": "taskItem",
5626                    "attrs": {"localId": "task-001", "state": "TODO"},
5627                    "content": []
5628                }]
5629            }]
5630        }"#;
5631        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5632        let md = adf_to_markdown(&doc).unwrap();
5633        assert!(md.contains("- [ ] "), "got: {md}");
5634        assert!(!md.contains("adf-unsupported"), "got: {md}");
5635    }
5636
5637    /// Issue #489: nested taskItem inside taskItem.content renders as indented
5638    /// task items instead of corrupting the surrounding taskList.
5639    #[test]
5640    fn adf_nested_task_item_renders_without_corruption() {
5641        let json = r#"{
5642            "type": "doc",
5643            "version": 1,
5644            "content": [{
5645                "type": "taskList",
5646                "attrs": {"localId": ""},
5647                "content": [
5648                    {
5649                        "type": "taskItem",
5650                        "attrs": {"localId": "aabbccdd-1234-5678-abcd-aabbccdd1234", "state": "TODO"},
5651                        "content": [{"type": "text", "text": "Normal task"}]
5652                    },
5653                    {
5654                        "type": "taskItem",
5655                        "attrs": {"localId": ""},
5656                        "content": [
5657                            {
5658                                "type": "taskItem",
5659                                "attrs": {"localId": "bbccddee-2345-6789-bcde-bbccddee2345", "state": "TODO"},
5660                                "content": [{"type": "text", "text": "Nested task one"}]
5661                            },
5662                            {
5663                                "type": "taskItem",
5664                                "attrs": {"localId": "ccddee11-3456-7890-cdef-ccddee113456", "state": "DONE"},
5665                                "content": [{"type": "text", "text": "Nested task two"}]
5666                            }
5667                        ]
5668                    }
5669                ]
5670            }]
5671        }"#;
5672        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5673        let md = adf_to_markdown(&doc).unwrap();
5674        // Normal task preserved
5675        assert!(md.contains("- [ ] Normal task"), "got: {md}");
5676        // Nested tasks rendered as indented task items, not adf-unsupported
5677        assert!(!md.contains("adf-unsupported"), "got: {md}");
5678        assert!(md.contains("  - [ ] Nested task one"), "got: {md}");
5679        assert!(md.contains("  - [x] Nested task two"), "got: {md}");
5680    }
5681
5682    /// Issue #489: round-trip of nested taskItem preserves data.
5683    #[test]
5684    fn round_trip_nested_task_item() {
5685        let json = r#"{
5686            "type": "doc",
5687            "version": 1,
5688            "content": [{
5689                "type": "taskList",
5690                "attrs": {"localId": ""},
5691                "content": [
5692                    {
5693                        "type": "taskItem",
5694                        "attrs": {"localId": "task-001", "state": "TODO"},
5695                        "content": [{"type": "text", "text": "Normal task"}]
5696                    },
5697                    {
5698                        "type": "taskItem",
5699                        "attrs": {"localId": ""},
5700                        "content": [
5701                            {
5702                                "type": "taskItem",
5703                                "attrs": {"localId": "task-002", "state": "TODO"},
5704                                "content": [{"type": "text", "text": "Nested one"}]
5705                            },
5706                            {
5707                                "type": "taskItem",
5708                                "attrs": {"localId": "task-003", "state": "DONE"},
5709                                "content": [{"type": "text", "text": "Nested two"}]
5710                            }
5711                        ]
5712                    }
5713                ]
5714            }]
5715        }"#;
5716        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5717        let md = adf_to_markdown(&doc).unwrap();
5718        let doc2 = markdown_to_adf(&md).unwrap();
5719
5720        // Top-level structure: taskList with 2 items
5721        assert_eq!(doc2.content[0].node_type, "taskList");
5722        let items = doc2.content[0].content.as_ref().unwrap();
5723        assert_eq!(items.len(), 2, "expected 2 top-level items, got: {items:?}");
5724
5725        // First item: normal task preserved
5726        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5727        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "task-001");
5728        let first_content = items[0].content.as_ref().unwrap();
5729        assert_eq!(first_content[0].text.as_deref(), Some("Normal task"));
5730
5731        // Second item: container taskItem — no spurious `state` attr
5732        let container = &items[1];
5733        assert_eq!(container.node_type, "taskItem");
5734        let c_attrs = container.attrs.as_ref().unwrap();
5735        assert!(
5736            c_attrs.get("state").is_none(),
5737            "container should have no state attr, got: {c_attrs:?}"
5738        );
5739
5740        // Children are bare taskItems, NOT wrapped in a taskList
5741        let container_content = container.content.as_ref().unwrap();
5742        assert_eq!(
5743            container_content.len(),
5744            2,
5745            "expected 2 bare taskItem children"
5746        );
5747        assert_eq!(container_content[0].node_type, "taskItem");
5748        assert_eq!(
5749            container_content[0].attrs.as_ref().unwrap()["state"],
5750            "TODO"
5751        );
5752        assert_eq!(
5753            container_content[0].attrs.as_ref().unwrap()["localId"],
5754            "task-002"
5755        );
5756        assert_eq!(container_content[1].node_type, "taskItem");
5757        assert_eq!(
5758            container_content[1].attrs.as_ref().unwrap()["state"],
5759            "DONE"
5760        );
5761        assert_eq!(
5762            container_content[1].attrs.as_ref().unwrap()["localId"],
5763            "task-003"
5764        );
5765    }
5766
5767    /// Issue #489: nested taskItem with localIds on both container and children.
5768    #[test]
5769    fn adf_nested_task_item_preserves_local_ids() {
5770        let json = r#"{
5771            "type": "doc",
5772            "version": 1,
5773            "content": [{
5774                "type": "taskList",
5775                "attrs": {"localId": "list-001"},
5776                "content": [{
5777                    "type": "taskItem",
5778                    "attrs": {"localId": "container-001", "state": "TODO"},
5779                    "content": [{
5780                        "type": "taskItem",
5781                        "attrs": {"localId": "child-001", "state": "DONE"},
5782                        "content": [{"type": "text", "text": "Nested child"}]
5783                    }]
5784                }]
5785            }]
5786        }"#;
5787        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5788        let md = adf_to_markdown(&doc).unwrap();
5789        // Container localId is emitted
5790        assert!(
5791            md.contains("localId=container-001"),
5792            "container localId missing: {md}"
5793        );
5794        // Child localId is emitted
5795        assert!(
5796            md.contains("localId=child-001"),
5797            "child localId missing: {md}"
5798        );
5799        assert!(!md.contains("adf-unsupported"), "got: {md}");
5800    }
5801
5802    /// Issue #489: nested taskItem content mixed with a non-taskItem block node.
5803    /// Covers the else branch in the renderer where a child is not a taskItem.
5804    #[test]
5805    fn adf_nested_task_item_mixed_with_block_node() {
5806        let json = r#"{
5807            "type": "doc",
5808            "version": 1,
5809            "content": [{
5810                "type": "taskList",
5811                "attrs": {"localId": ""},
5812                "content": [{
5813                    "type": "taskItem",
5814                    "attrs": {"localId": "", "state": "TODO"},
5815                    "content": [
5816                        {
5817                            "type": "taskItem",
5818                            "attrs": {"localId": "", "state": "TODO"},
5819                            "content": [{"type": "text", "text": "A nested task"}]
5820                        },
5821                        {
5822                            "type": "paragraph",
5823                            "content": [{"type": "text", "text": "Stray paragraph"}]
5824                        }
5825                    ]
5826                }]
5827            }]
5828        }"#;
5829        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5830        let md = adf_to_markdown(&doc).unwrap();
5831        assert!(md.contains("  - [ ] A nested task"), "got: {md}");
5832        assert!(md.contains("  Stray paragraph"), "got: {md}");
5833        assert!(!md.contains("adf-unsupported"), "got: {md}");
5834    }
5835
5836    /// Issue #489: task item with inline text AND indented sub-content.
5837    /// Covers the parser's `Some` branch when appending nested blocks to
5838    /// an existing content vec.
5839    #[test]
5840    fn task_item_with_text_and_nested_sub_content() {
5841        let md = "- [ ] Parent task\n  - [ ] Sub task\n";
5842        let doc = markdown_to_adf(md).unwrap();
5843        assert_eq!(doc.content[0].node_type, "taskList");
5844        let items = doc.content[0].content.as_ref().unwrap();
5845        // Issue #506: the nested taskList is a sibling of the taskItem,
5846        // not a child — matching ADF's canonical structure.
5847        assert_eq!(items.len(), 2, "got: {items:?}");
5848        let parent = &items[0];
5849        assert_eq!(parent.attrs.as_ref().unwrap()["state"], "TODO");
5850        let parent_content = parent.content.as_ref().unwrap();
5851        assert_eq!(parent_content[0].text.as_deref(), Some("Parent task"));
5852        // Second item: nested taskList (sibling)
5853        assert_eq!(items[1].node_type, "taskList");
5854        let nested = items[1].content.as_ref().unwrap();
5855        assert_eq!(nested.len(), 1);
5856        assert_eq!(nested[0].attrs.as_ref().unwrap()["state"], "TODO");
5857    }
5858
5859    /// Issue #489: empty task item with non-taskList sub-content (e.g. a
5860    /// paragraph).  Exercises the `None` branch when the sub-content does
5861    /// not qualify for container-unwrap.
5862    #[test]
5863    fn task_item_empty_with_non_tasklist_sub_content() {
5864        let md = "- [ ] \n  Some paragraph text\n";
5865        let doc = markdown_to_adf(md).unwrap();
5866        assert_eq!(doc.content[0].node_type, "taskList");
5867        let items = doc.content[0].content.as_ref().unwrap();
5868        assert_eq!(items.len(), 1);
5869        let item = &items[0];
5870        assert_eq!(item.attrs.as_ref().unwrap()["state"], "TODO");
5871        let content = item.content.as_ref().unwrap();
5872        // Sub-content is a paragraph (not unwrapped since it's not a taskList)
5873        assert_eq!(content[0].node_type, "paragraph");
5874    }
5875
5876    /// Issue #489: single nested taskItem (edge case — only one child).
5877    #[test]
5878    fn adf_nested_task_item_single_child() {
5879        let json = r#"{
5880            "type": "doc",
5881            "version": 1,
5882            "content": [{
5883                "type": "taskList",
5884                "attrs": {"localId": ""},
5885                "content": [{
5886                    "type": "taskItem",
5887                    "attrs": {"localId": "", "state": "TODO"},
5888                    "content": [{
5889                        "type": "taskItem",
5890                        "attrs": {"localId": "", "state": "DONE"},
5891                        "content": [{"type": "text", "text": "Only child"}]
5892                    }]
5893                }]
5894            }]
5895        }"#;
5896        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5897        let md = adf_to_markdown(&doc).unwrap();
5898        assert!(md.contains("  - [x] Only child"), "got: {md}");
5899        assert!(!md.contains("adf-unsupported"), "got: {md}");
5900    }
5901
5902    /// Issue #506: nested taskList as direct child of outer taskList is
5903    /// rendered indented so it round-trips back as taskList, not taskItem.
5904    #[test]
5905    fn adf_nested_tasklist_sibling_renders_indented() {
5906        let json = r#"{
5907            "version": 1,
5908            "type": "doc",
5909            "content": [{
5910                "type": "taskList",
5911                "attrs": {"localId": ""},
5912                "content": [
5913                    {
5914                        "type": "taskItem",
5915                        "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000001", "state": "TODO"},
5916                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task one"}]}]
5917                    },
5918                    {
5919                        "type": "taskList",
5920                        "attrs": {"localId": ""},
5921                        "content": [{
5922                            "type": "taskItem",
5923                            "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000002", "state": "TODO"},
5924                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "nested sub-task"}]}]
5925                        }]
5926                    },
5927                    {
5928                        "type": "taskItem",
5929                        "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000003", "state": "TODO"},
5930                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task two"}]}]
5931                    }
5932                ]
5933            }]
5934        }"#;
5935        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5936        let md = adf_to_markdown(&doc).unwrap();
5937        // The nested taskList should be indented under the preceding item.
5938        assert!(md.contains("- [ ] parent task one"), "got: {md}");
5939        assert!(md.contains("  - [ ] nested sub-task"), "got: {md}");
5940        assert!(md.contains("- [ ] parent task two"), "got: {md}");
5941    }
5942
5943    /// Issue #506: round-trip preserves nested taskList type.
5944    #[test]
5945    fn round_trip_nested_tasklist_preserves_type() {
5946        let json = r#"{
5947            "version": 1,
5948            "type": "doc",
5949            "content": [{
5950                "type": "taskList",
5951                "attrs": {"localId": ""},
5952                "content": [
5953                    {
5954                        "type": "taskItem",
5955                        "attrs": {"localId": "", "state": "TODO"},
5956                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task one"}]}]
5957                    },
5958                    {
5959                        "type": "taskList",
5960                        "attrs": {"localId": ""},
5961                        "content": [{
5962                            "type": "taskItem",
5963                            "attrs": {"localId": "", "state": "TODO"},
5964                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "nested sub-task"}]}]
5965                        }]
5966                    },
5967                    {
5968                        "type": "taskItem",
5969                        "attrs": {"localId": "", "state": "TODO"},
5970                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task two"}]}]
5971                    }
5972                ]
5973            }]
5974        }"#;
5975        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5976        let md = adf_to_markdown(&doc).unwrap();
5977        let rt_doc = markdown_to_adf(&md).unwrap();
5978        // The outer taskList should still be present.
5979        assert_eq!(rt_doc.content[0].node_type, "taskList");
5980        let items = rt_doc.content[0].content.as_ref().unwrap();
5981        // The nested taskList is a sibling of the taskItem nodes,
5982        // matching the original ADF structure (issue #506).
5983        assert_eq!(items.len(), 3, "got: {items:?}");
5984        assert_eq!(items[0].node_type, "taskItem");
5985        assert_eq!(
5986            items[1].node_type, "taskList",
5987            "nested taskList should survive round-trip"
5988        );
5989        assert_eq!(items[2].node_type, "taskItem");
5990        let nested_items = items[1].content.as_ref().unwrap();
5991        assert_eq!(nested_items[0].attrs.as_ref().unwrap()["state"], "TODO");
5992    }
5993
5994    /// Issue #506: nested taskList with DONE state preserves checkbox.
5995    #[test]
5996    fn adf_nested_tasklist_done_state() {
5997        let json = r#"{
5998            "version": 1,
5999            "type": "doc",
6000            "content": [{
6001                "type": "taskList",
6002                "attrs": {"localId": ""},
6003                "content": [
6004                    {
6005                        "type": "taskItem",
6006                        "attrs": {"localId": "", "state": "TODO"},
6007                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent"}]}]
6008                    },
6009                    {
6010                        "type": "taskList",
6011                        "attrs": {"localId": ""},
6012                        "content": [{
6013                            "type": "taskItem",
6014                            "attrs": {"localId": "", "state": "DONE"},
6015                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "done child"}]}]
6016                        }]
6017                    }
6018                ]
6019            }]
6020        }"#;
6021        let doc: AdfDocument = serde_json::from_str(json).unwrap();
6022        let md = adf_to_markdown(&doc).unwrap();
6023        assert!(md.contains("  - [x] done child"), "got: {md}");
6024        // Round-trip preserves DONE state — nested taskList is a sibling.
6025        let rt_doc = markdown_to_adf(&md).unwrap();
6026        let items = rt_doc.content[0].content.as_ref().unwrap();
6027        assert_eq!(
6028            items[1].node_type, "taskList",
6029            "nested taskList should survive round-trip"
6030        );
6031        let nested_item = &items[1].content.as_ref().unwrap()[0];
6032        assert_eq!(nested_item.attrs.as_ref().unwrap()["state"], "DONE");
6033    }
6034
6035    /// Issue #506: multiple nested taskLists at the same level.
6036    #[test]
6037    fn adf_multiple_nested_tasklists() {
6038        let json = r#"{
6039            "version": 1,
6040            "type": "doc",
6041            "content": [{
6042                "type": "taskList",
6043                "attrs": {"localId": ""},
6044                "content": [
6045                    {
6046                        "type": "taskItem",
6047                        "attrs": {"localId": "", "state": "TODO"},
6048                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "first parent"}]}]
6049                    },
6050                    {
6051                        "type": "taskList",
6052                        "attrs": {"localId": ""},
6053                        "content": [{
6054                            "type": "taskItem",
6055                            "attrs": {"localId": "", "state": "TODO"},
6056                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child A"}]}]
6057                        }]
6058                    },
6059                    {
6060                        "type": "taskItem",
6061                        "attrs": {"localId": "", "state": "TODO"},
6062                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "second parent"}]}]
6063                    },
6064                    {
6065                        "type": "taskList",
6066                        "attrs": {"localId": ""},
6067                        "content": [{
6068                            "type": "taskItem",
6069                            "attrs": {"localId": "", "state": "DONE"},
6070                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child B"}]}]
6071                        }]
6072                    }
6073                ]
6074            }]
6075        }"#;
6076        let doc: AdfDocument = serde_json::from_str(json).unwrap();
6077        let md = adf_to_markdown(&doc).unwrap();
6078        assert!(md.contains("- [ ] first parent"), "got: {md}");
6079        assert!(md.contains("  - [ ] child A"), "got: {md}");
6080        assert!(md.contains("- [ ] second parent"), "got: {md}");
6081        assert!(md.contains("  - [x] child B"), "got: {md}");
6082    }
6083
6084    /// Issue #506: second round-trip is stable (idempotent after first
6085    /// structural normalisation).
6086    #[test]
6087    fn round_trip_nested_tasklist_stable() {
6088        let json = r#"{
6089            "version": 1,
6090            "type": "doc",
6091            "content": [{
6092                "type": "taskList",
6093                "attrs": {"localId": ""},
6094                "content": [
6095                    {
6096                        "type": "taskItem",
6097                        "attrs": {"localId": "", "state": "TODO"},
6098                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent"}]}]
6099                    },
6100                    {
6101                        "type": "taskList",
6102                        "attrs": {"localId": ""},
6103                        "content": [{
6104                            "type": "taskItem",
6105                            "attrs": {"localId": "", "state": "TODO"},
6106                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child"}]}]
6107                        }]
6108                    }
6109                ]
6110            }]
6111        }"#;
6112        let doc: AdfDocument = serde_json::from_str(json).unwrap();
6113        // First round-trip.
6114        let md1 = adf_to_markdown(&doc).unwrap();
6115        let rt1 = markdown_to_adf(&md1).unwrap();
6116        // Second round-trip.
6117        let md2 = adf_to_markdown(&rt1).unwrap();
6118        let rt2 = markdown_to_adf(&md2).unwrap();
6119        // Markdown output should be identical after first normalisation.
6120        assert_eq!(md1, md2, "markdown should be stable across round-trips");
6121        // ADF structure should also be stable.
6122        let rt1_json = serde_json::to_string(&rt1).unwrap();
6123        let rt2_json = serde_json::to_string(&rt2).unwrap();
6124        assert_eq!(
6125            rt1_json, rt2_json,
6126            "ADF should be stable across round-trips"
6127        );
6128    }
6129
6130    /// Issue #506: task item with text and mixed indented sub-content
6131    /// (taskList + non-taskList block).  Exercises the `child_nodes` branch
6132    /// where non-taskList blocks stay as children of the taskItem while
6133    /// taskLists are promoted to siblings.
6134    #[test]
6135    fn task_item_mixed_sub_content_splits_siblings() {
6136        let md = "- [ ] Parent task\n  - [ ] Sub task\n  Some paragraph\n";
6137        let doc = markdown_to_adf(md).unwrap();
6138        let items = doc.content[0].content.as_ref().unwrap();
6139        // taskItem + sibling taskList
6140        assert_eq!(items.len(), 2, "got: {items:?}");
6141        assert_eq!(items[0].node_type, "taskItem");
6142        let parent_content = items[0].content.as_ref().unwrap();
6143        // Inline text + paragraph block (the non-taskList sub-content)
6144        assert!(
6145            parent_content.iter().any(|n| n.node_type == "paragraph"),
6146            "non-taskList sub-content should stay as child: {parent_content:?}"
6147        );
6148        // Sibling taskList
6149        assert_eq!(items[1].node_type, "taskList");
6150    }
6151
6152    /// Issue #506: empty task item with mixed indented sub-content hits the
6153    /// `None` arm of the `task.content` match when promoting taskLists to
6154    /// siblings.
6155    #[test]
6156    fn empty_task_item_mixed_sub_content_none_arm() {
6157        let md = "- [ ] \n  Some paragraph\n  - [ ] Sub task\n";
6158        let doc = markdown_to_adf(md).unwrap();
6159        let items = doc.content[0].content.as_ref().unwrap();
6160        // taskItem (with paragraph child) + sibling taskList
6161        assert_eq!(items.len(), 2, "got: {items:?}");
6162        assert_eq!(items[0].node_type, "taskItem");
6163        let parent_content = items[0].content.as_ref().unwrap();
6164        assert!(
6165            parent_content.iter().any(|n| n.node_type == "paragraph"),
6166            "paragraph should be assigned to taskItem: {parent_content:?}"
6167        );
6168        assert_eq!(items[1].node_type, "taskList");
6169    }
6170
6171    /// Issue #506: task item with text and only non-taskList sub-content
6172    /// (no sibling taskLists).  Exercises the fall-through path where
6173    /// `sibling_task_lists` is empty and child_nodes are appended to
6174    /// the existing task content (Some arm).
6175    #[test]
6176    fn task_item_text_with_non_tasklist_sub_content_only() {
6177        let md = "- [ ] My task\n  Extra paragraph content\n";
6178        let doc = markdown_to_adf(md).unwrap();
6179        let items = doc.content[0].content.as_ref().unwrap();
6180        // Single taskItem — no sibling taskLists to extract.
6181        assert_eq!(items.len(), 1, "got: {items:?}");
6182        assert_eq!(items[0].node_type, "taskItem");
6183        let content = items[0].content.as_ref().unwrap();
6184        // Inline text + sub-paragraph
6185        assert!(
6186            content.iter().any(|n| n.node_type == "paragraph"),
6187            "paragraph sub-content should be a child of taskItem: {content:?}"
6188        );
6189    }
6190
6191    /// Covers the else branch in render_list_item_content where the first
6192    /// child of a list item is a block node (not paragraph, not inline).
6193    #[test]
6194    fn adf_list_item_leading_block_node() {
6195        let json = r#"{
6196            "version": 1,
6197            "type": "doc",
6198            "content": [{
6199                "type": "bulletList",
6200                "content": [{
6201                    "type": "listItem",
6202                    "content": [{
6203                        "type": "codeBlock",
6204                        "attrs": {"language": "rust"},
6205                        "content": [{"type": "text", "text": "let x = 1;"}]
6206                    }]
6207                }]
6208            }]
6209        }"#;
6210        let doc: AdfDocument = serde_json::from_str(json).unwrap();
6211        let md = adf_to_markdown(&doc).unwrap();
6212        assert!(md.contains("```rust"), "got: {md}");
6213        assert!(md.contains("let x = 1;"), "got: {md}");
6214        // Continuation lines must be indented so the block stays inside
6215        // the list item on round-trip (issue #511).
6216        for line in md.lines() {
6217            if line.starts_with("- ") {
6218                continue; // first line with list marker
6219            }
6220            if line.trim().is_empty() {
6221                continue;
6222            }
6223            assert!(
6224                line.starts_with("  "),
6225                "continuation line not indented: {line:?}"
6226            );
6227        }
6228    }
6229
6230    /// Round-trip a codeBlock inside a listItem whose content contains a
6231    /// backtick character — the exact reproducer from issue #511.
6232    #[test]
6233    fn code_block_in_list_item_backtick_roundtrip() {
6234        let json = r#"{
6235            "version": 1,
6236            "type": "doc",
6237            "content": [{
6238                "type": "bulletList",
6239                "content": [{
6240                    "type": "listItem",
6241                    "content": [{
6242                        "type": "codeBlock",
6243                        "attrs": {"language": ""},
6244                        "content": [{"type": "text", "text": "error: some value with a backtick ` at end"}]
6245                    }]
6246                }]
6247            }]
6248        }"#;
6249        let original: AdfDocument = serde_json::from_str(json).unwrap();
6250        let md = adf_to_markdown(&original).unwrap();
6251        let roundtripped = markdown_to_adf(&md).unwrap();
6252        let list = &roundtripped.content[0];
6253        assert_eq!(list.node_type, "bulletList", "top node: {}", list.node_type);
6254        let item = &list.content.as_ref().unwrap()[0];
6255        let first_child = &item.content.as_ref().unwrap()[0];
6256        assert_eq!(
6257            first_child.node_type, "codeBlock",
6258            "expected codeBlock, got: {}",
6259            first_child.node_type
6260        );
6261        let text = first_child.content.as_ref().unwrap()[0]
6262            .text
6263            .as_deref()
6264            .unwrap();
6265        assert_eq!(text, "error: some value with a backtick ` at end");
6266    }
6267
6268    /// Code block with language tag inside a list item round-trips.
6269    #[test]
6270    fn code_block_with_language_in_list_item_roundtrip() {
6271        let json = r#"{
6272            "version": 1,
6273            "type": "doc",
6274            "content": [{
6275                "type": "bulletList",
6276                "content": [{
6277                    "type": "listItem",
6278                    "content": [{
6279                        "type": "codeBlock",
6280                        "attrs": {"language": "rust"},
6281                        "content": [{"type": "text", "text": "fn main() {\n    println!(\"hello\");\n}"}]
6282                    }]
6283                }]
6284            }]
6285        }"#;
6286        let original: AdfDocument = serde_json::from_str(json).unwrap();
6287        let md = adf_to_markdown(&original).unwrap();
6288        let roundtripped = markdown_to_adf(&md).unwrap();
6289        let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
6290        let code = &item.content.as_ref().unwrap()[0];
6291        assert_eq!(code.node_type, "codeBlock");
6292        let lang = code
6293            .attrs
6294            .as_ref()
6295            .and_then(|a| a.get("language"))
6296            .and_then(serde_json::Value::as_str)
6297            .unwrap_or("");
6298        assert_eq!(lang, "rust");
6299        let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
6300        assert!(text.contains("println!"), "code content: {text}");
6301    }
6302
6303    /// Code block in an ordered list item round-trips correctly.
6304    #[test]
6305    fn code_block_in_ordered_list_item_roundtrip() {
6306        let json = r#"{
6307            "version": 1,
6308            "type": "doc",
6309            "content": [{
6310                "type": "orderedList",
6311                "attrs": {"order": 1},
6312                "content": [{
6313                    "type": "listItem",
6314                    "content": [{
6315                        "type": "codeBlock",
6316                        "attrs": {"language": ""},
6317                        "content": [{"type": "text", "text": "backtick ` here"}]
6318                    }]
6319                }]
6320            }]
6321        }"#;
6322        let original: AdfDocument = serde_json::from_str(json).unwrap();
6323        let md = adf_to_markdown(&original).unwrap();
6324        let roundtripped = markdown_to_adf(&md).unwrap();
6325        let list = &roundtripped.content[0];
6326        assert_eq!(list.node_type, "orderedList");
6327        let item = &list.content.as_ref().unwrap()[0];
6328        let code = &item.content.as_ref().unwrap()[0];
6329        assert_eq!(code.node_type, "codeBlock");
6330        let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
6331        assert_eq!(text, "backtick ` here");
6332    }
6333
6334    /// A list item with a code block followed by a paragraph round-trips.
6335    #[test]
6336    fn code_block_then_paragraph_in_list_item() {
6337        let json = r#"{
6338            "version": 1,
6339            "type": "doc",
6340            "content": [{
6341                "type": "bulletList",
6342                "content": [{
6343                    "type": "listItem",
6344                    "content": [
6345                        {
6346                            "type": "codeBlock",
6347                            "attrs": {"language": ""},
6348                            "content": [{"type": "text", "text": "code with ` backtick"}]
6349                        },
6350                        {
6351                            "type": "paragraph",
6352                            "content": [{"type": "text", "text": "description"}]
6353                        }
6354                    ]
6355                }]
6356            }]
6357        }"#;
6358        let original: AdfDocument = serde_json::from_str(json).unwrap();
6359        let md = adf_to_markdown(&original).unwrap();
6360        let roundtripped = markdown_to_adf(&md).unwrap();
6361        let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
6362        let children = item.content.as_ref().unwrap();
6363        assert_eq!(children[0].node_type, "codeBlock");
6364        assert_eq!(children[1].node_type, "paragraph");
6365    }
6366
6367    /// Multiple backticks in code block content round-trip.
6368    #[test]
6369    fn code_block_multiple_backticks_in_list_item() {
6370        let json = r#"{
6371            "version": 1,
6372            "type": "doc",
6373            "content": [{
6374                "type": "bulletList",
6375                "content": [{
6376                    "type": "listItem",
6377                    "content": [{
6378                        "type": "codeBlock",
6379                        "attrs": {"language": ""},
6380                        "content": [{"type": "text", "text": "a ` b `` c ``` d"}]
6381                    }]
6382                }]
6383            }]
6384        }"#;
6385        let original: AdfDocument = serde_json::from_str(json).unwrap();
6386        let md = adf_to_markdown(&original).unwrap();
6387        let roundtripped = markdown_to_adf(&md).unwrap();
6388        let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
6389        let code = &item.content.as_ref().unwrap()[0];
6390        assert_eq!(code.node_type, "codeBlock");
6391        let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
6392        assert_eq!(text, "a ` b `` c ``` d");
6393    }
6394
6395    /// Media as the first child of a list item with a subsequent paragraph
6396    /// exercises the media + sub_lines branch in `parse_list_item_first_line`.
6397    #[test]
6398    fn media_first_child_with_sub_content_in_list_item() {
6399        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
6400          {"type":"listItem","content":[
6401            {"type":"mediaSingle","attrs":{"layout":"center"},
6402             "content":[{"type":"media","attrs":{"type":"file","id":"img-99","collection":"col-x","height":50,"width":100}}]},
6403            {"type":"paragraph","content":[{"type":"text","text":"Caption below"}]}
6404          ]}
6405        ]}]}"#;
6406        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
6407        let md = adf_to_markdown(&doc).unwrap();
6408        let rt = markdown_to_adf(&md).unwrap();
6409        let item = &rt.content[0].content.as_ref().unwrap()[0];
6410        let children = item.content.as_ref().unwrap();
6411        assert_eq!(
6412            children.len(),
6413            2,
6414            "expected 2 children, got {}",
6415            children.len()
6416        );
6417        assert_eq!(children[0].node_type, "mediaSingle");
6418        let media = &children[0].content.as_ref().unwrap()[0];
6419        assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-99");
6420        assert_eq!(children[1].node_type, "paragraph");
6421    }
6422
6423    #[test]
6424    fn inline_bold() {
6425        let doc = markdown_to_adf("Some **bold** text").unwrap();
6426        let content = doc.content[0].content.as_ref().unwrap();
6427        assert!(content.len() >= 3);
6428        let bold_node = &content[1];
6429        assert_eq!(bold_node.text.as_deref(), Some("bold"));
6430        let marks = bold_node.marks.as_ref().unwrap();
6431        assert_eq!(marks[0].mark_type, "strong");
6432    }
6433
6434    #[test]
6435    fn inline_italic() {
6436        let doc = markdown_to_adf("Some *italic* text").unwrap();
6437        let content = doc.content[0].content.as_ref().unwrap();
6438        let italic_node = &content[1];
6439        assert_eq!(italic_node.text.as_deref(), Some("italic"));
6440        let marks = italic_node.marks.as_ref().unwrap();
6441        assert_eq!(marks[0].mark_type, "em");
6442    }
6443
6444    #[test]
6445    fn inline_code() {
6446        let doc = markdown_to_adf("Use `code` here").unwrap();
6447        let content = doc.content[0].content.as_ref().unwrap();
6448        let code_node = &content[1];
6449        assert_eq!(code_node.text.as_deref(), Some("code"));
6450        let marks = code_node.marks.as_ref().unwrap();
6451        assert_eq!(marks[0].mark_type, "code");
6452    }
6453
6454    /// Issue #578: a code-marked text with an internal backtick must be
6455    /// emitted using double-backtick delimiters so it round-trips as a
6456    /// single node rather than being split on the inner backtick.
6457    #[test]
6458    fn inline_code_with_backtick_emitted_with_double_delimiters() {
6459        let doc = AdfDocument {
6460            version: 1,
6461            doc_type: "doc".to_string(),
6462            content: vec![AdfNode::paragraph(vec![
6463                AdfNode::text("Run "),
6464                AdfNode::text_with_marks(
6465                    "ADD `custom_threshold` TEXT NOT NULL",
6466                    vec![AdfMark::code()],
6467                ),
6468                AdfNode::text(" to update the schema."),
6469            ])],
6470        };
6471        let md = adf_to_markdown(&doc).unwrap();
6472        assert!(
6473            md.contains("``ADD `custom_threshold` TEXT NOT NULL``"),
6474            "expected double-backtick delimiters, got: {md}"
6475        );
6476    }
6477
6478    /// Issue #578: double-backtick delimited code spans parse as a single
6479    /// code-marked text node that preserves the embedded single backticks.
6480    #[test]
6481    fn inline_code_double_backtick_delimiters_parse() {
6482        let doc = markdown_to_adf("Run ``ADD `custom_threshold` TEXT NOT NULL`` now").unwrap();
6483        let content = doc.content[0].content.as_ref().unwrap();
6484        assert_eq!(content.len(), 3, "content: {content:?}");
6485        let code_node = &content[1];
6486        assert_eq!(
6487            code_node.text.as_deref(),
6488            Some("ADD `custom_threshold` TEXT NOT NULL")
6489        );
6490        let marks = code_node.marks.as_ref().unwrap();
6491        assert_eq!(marks[0].mark_type, "code");
6492    }
6493
6494    /// Issue #578: the full reproducer — a code-marked text with inner
6495    /// backticks survives ADF → JFM → ADF round-trip intact.
6496    #[test]
6497    fn inline_code_with_backtick_roundtrip() {
6498        let json = r#"{
6499            "version": 1,
6500            "type": "doc",
6501            "content": [{
6502                "type": "paragraph",
6503                "content": [
6504                    {"type": "text", "text": "Run "},
6505                    {
6506                        "type": "text",
6507                        "text": "ADD `custom_threshold` TEXT NOT NULL",
6508                        "marks": [{"type": "code"}]
6509                    },
6510                    {"type": "text", "text": " to update the schema."}
6511                ]
6512            }]
6513        }"#;
6514        let original: AdfDocument = serde_json::from_str(json).unwrap();
6515        let md = adf_to_markdown(&original).unwrap();
6516        let roundtripped = markdown_to_adf(&md).unwrap();
6517        let para = &roundtripped.content[0];
6518        let children = para.content.as_ref().unwrap();
6519        assert_eq!(children.len(), 3, "expected 3 children, got: {children:?}");
6520        assert_eq!(children[0].text.as_deref(), Some("Run "));
6521        assert_eq!(
6522            children[1].text.as_deref(),
6523            Some("ADD `custom_threshold` TEXT NOT NULL")
6524        );
6525        let marks = children[1].marks.as_ref().unwrap();
6526        assert_eq!(marks.len(), 1);
6527        assert_eq!(marks[0].mark_type, "code");
6528        assert_eq!(children[2].text.as_deref(), Some(" to update the schema."));
6529    }
6530
6531    /// A code-marked text containing a run of two backticks should be
6532    /// emitted with triple-backtick delimiters and round-trip intact —
6533    /// the first line of the paragraph also starts with the fence so this
6534    /// exercises the info-string-with-backtick fence-opener rejection.
6535    #[test]
6536    fn inline_code_with_double_backtick_roundtrip() {
6537        let doc = AdfDocument {
6538            version: 1,
6539            doc_type: "doc".to_string(),
6540            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6541                "x `` y",
6542                vec![AdfMark::code()],
6543            )])],
6544        };
6545        let md = adf_to_markdown(&doc).unwrap();
6546        let roundtripped = markdown_to_adf(&md).unwrap();
6547        let content = roundtripped.content[0].content.as_ref().unwrap();
6548        assert_eq!(content.len(), 1);
6549        assert_eq!(content[0].text.as_deref(), Some("x `` y"));
6550        let marks = content[0].marks.as_ref().unwrap();
6551        assert_eq!(marks[0].mark_type, "code");
6552    }
6553
6554    /// A code-marked text that begins with a backtick must be padded on
6555    /// both sides so the CommonMark space-stripping rule reconstructs it.
6556    #[test]
6557    fn inline_code_leading_backtick_roundtrip() {
6558        let doc = AdfDocument {
6559            version: 1,
6560            doc_type: "doc".to_string(),
6561            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6562                "`start",
6563                vec![AdfMark::code()],
6564            )])],
6565        };
6566        let md = adf_to_markdown(&doc).unwrap();
6567        let roundtripped = markdown_to_adf(&md).unwrap();
6568        let content = roundtripped.content[0].content.as_ref().unwrap();
6569        assert_eq!(content[0].text.as_deref(), Some("`start"));
6570        assert_eq!(content[0].marks.as_ref().unwrap()[0].mark_type, "code");
6571    }
6572
6573    /// A code-marked text that ends with a backtick must also survive.
6574    #[test]
6575    fn inline_code_trailing_backtick_roundtrip() {
6576        let doc = AdfDocument {
6577            version: 1,
6578            doc_type: "doc".to_string(),
6579            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6580                "end`",
6581                vec![AdfMark::code()],
6582            )])],
6583        };
6584        let md = adf_to_markdown(&doc).unwrap();
6585        let roundtripped = markdown_to_adf(&md).unwrap();
6586        let content = roundtripped.content[0].content.as_ref().unwrap();
6587        assert_eq!(content[0].text.as_deref(), Some("end`"));
6588    }
6589
6590    /// Content that both begins and ends with a space (but is not all
6591    /// spaces) needs padding so the stripping rule leaves it intact.
6592    #[test]
6593    fn inline_code_space_padded_content_roundtrip() {
6594        let doc = AdfDocument {
6595            version: 1,
6596            doc_type: "doc".to_string(),
6597            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6598                " foo ",
6599                vec![AdfMark::code()],
6600            )])],
6601        };
6602        let md = adf_to_markdown(&doc).unwrap();
6603        let roundtripped = markdown_to_adf(&md).unwrap();
6604        let content = roundtripped.content[0].content.as_ref().unwrap();
6605        assert_eq!(content[0].text.as_deref(), Some(" foo "));
6606    }
6607
6608    /// All-space content must round-trip without the stripping rule
6609    /// kicking in (per CommonMark: all-space content is not stripped).
6610    #[test]
6611    fn inline_code_all_spaces_roundtrip() {
6612        let doc = AdfDocument {
6613            version: 1,
6614            doc_type: "doc".to_string(),
6615            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6616                "   ",
6617                vec![AdfMark::code()],
6618            )])],
6619        };
6620        let md = adf_to_markdown(&doc).unwrap();
6621        let roundtripped = markdown_to_adf(&md).unwrap();
6622        let content = roundtripped.content[0].content.as_ref().unwrap();
6623        assert_eq!(content[0].text.as_deref(), Some("   "));
6624    }
6625
6626    /// A code+link mark where the code text contains a backtick must also
6627    /// round-trip — verifies the link branch of code-span rendering.
6628    #[test]
6629    fn inline_code_with_link_and_backtick_roundtrip() {
6630        let doc = AdfDocument {
6631            version: 1,
6632            doc_type: "doc".to_string(),
6633            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6634                "fn `inner`",
6635                vec![AdfMark::code(), AdfMark::link("https://example.com")],
6636            )])],
6637        };
6638        let md = adf_to_markdown(&doc).unwrap();
6639        assert!(
6640            md.contains("`` fn `inner` ``"),
6641            "expected padded double-backtick delimiters inside link, got: {md}"
6642        );
6643        let roundtripped = markdown_to_adf(&md).unwrap();
6644        let content = roundtripped.content[0].content.as_ref().unwrap();
6645        assert_eq!(content[0].text.as_deref(), Some("fn `inner`"));
6646        let mark_types: Vec<&str> = content[0]
6647            .marks
6648            .as_ref()
6649            .unwrap()
6650            .iter()
6651            .map(|m| m.mark_type.as_str())
6652            .collect();
6653        assert!(mark_types.contains(&"code"));
6654        assert!(mark_types.contains(&"link"));
6655    }
6656
6657    /// Unmatched opening backticks must not be parsed as a code span.
6658    #[test]
6659    fn inline_code_unmatched_run_is_plain_text() {
6660        let doc = markdown_to_adf("foo ``bar baz").unwrap();
6661        let content = doc.content[0].content.as_ref().unwrap();
6662        assert_eq!(content.len(), 1);
6663        assert_eq!(content[0].text.as_deref(), Some("foo ``bar baz"));
6664        assert!(content[0].marks.is_none());
6665    }
6666
6667    /// Mismatched delimiter lengths must not form a code span.  Per
6668    /// CommonMark the opening 2-backtick run and the trailing 1-backtick
6669    /// run never form a valid code span and the characters stay literal.
6670    #[test]
6671    fn inline_code_mismatched_delimiters_is_plain_text() {
6672        let doc = markdown_to_adf("``foo` bar").unwrap();
6673        let content = doc.content[0].content.as_ref().unwrap();
6674        assert_eq!(content.len(), 1);
6675        assert_eq!(content[0].text.as_deref(), Some("``foo` bar"));
6676        assert!(content[0].marks.is_none());
6677    }
6678
6679    #[test]
6680    fn inline_code_delimiter_chooses_correct_length() {
6681        assert_eq!(inline_code_delimiter("no ticks"), (1, false));
6682        assert_eq!(inline_code_delimiter("one ` here"), (2, false));
6683        assert_eq!(inline_code_delimiter("two `` here"), (3, false));
6684        assert_eq!(inline_code_delimiter("three ``` here"), (4, false));
6685        assert_eq!(inline_code_delimiter("`leading"), (2, true));
6686        assert_eq!(inline_code_delimiter("trailing`"), (2, true));
6687        assert_eq!(inline_code_delimiter(" foo "), (1, true));
6688        assert_eq!(inline_code_delimiter(" "), (1, false));
6689        assert_eq!(inline_code_delimiter("   "), (1, false));
6690        assert_eq!(inline_code_delimiter(" foo"), (1, false));
6691    }
6692
6693    #[test]
6694    fn try_parse_inline_code_strips_paired_spaces() {
6695        let (end, content) = try_parse_inline_code("`` `foo` ``", 0).unwrap();
6696        assert_eq!(end, 11);
6697        assert_eq!(content, "`foo`");
6698    }
6699
6700    #[test]
6701    fn try_parse_inline_code_all_space_content_is_preserved() {
6702        let (_end, content) = try_parse_inline_code("`   `", 0).unwrap();
6703        assert_eq!(content, "   ");
6704    }
6705
6706    #[test]
6707    fn try_parse_inline_code_single_run_matches_first_close() {
6708        let (end, content) = try_parse_inline_code("`foo` tail", 0).unwrap();
6709        assert_eq!(end, 5);
6710        assert_eq!(content, "foo");
6711    }
6712
6713    #[test]
6714    fn try_parse_inline_code_no_match_returns_none() {
6715        assert!(try_parse_inline_code("``unmatched", 0).is_none());
6716        assert!(try_parse_inline_code("plain text", 0).is_none());
6717    }
6718
6719    #[test]
6720    fn is_code_fence_opener_rejects_info_with_backtick() {
6721        assert!(is_code_fence_opener("```"));
6722        assert!(is_code_fence_opener("```rust"));
6723        assert!(is_code_fence_opener("```\"\""));
6724        assert!(!is_code_fence_opener("```x `` y```"));
6725        assert!(!is_code_fence_opener("``not-enough"));
6726        assert!(!is_code_fence_opener("no fence"));
6727    }
6728
6729    #[test]
6730    fn inline_strikethrough() {
6731        let doc = markdown_to_adf("Some ~~deleted~~ text").unwrap();
6732        let content = doc.content[0].content.as_ref().unwrap();
6733        let strike_node = &content[1];
6734        assert_eq!(strike_node.text.as_deref(), Some("deleted"));
6735        let marks = strike_node.marks.as_ref().unwrap();
6736        assert_eq!(marks[0].mark_type, "strike");
6737    }
6738
6739    #[test]
6740    fn inline_link() {
6741        let doc = markdown_to_adf("Click [here](https://example.com) now").unwrap();
6742        let content = doc.content[0].content.as_ref().unwrap();
6743        let link_node = &content[1];
6744        assert_eq!(link_node.text.as_deref(), Some("here"));
6745        let marks = link_node.marks.as_ref().unwrap();
6746        assert_eq!(marks[0].mark_type, "link");
6747    }
6748
6749    #[test]
6750    fn block_image() {
6751        let md = "![Alt text](https://example.com/image.png)";
6752        let doc = markdown_to_adf(md).unwrap();
6753        assert_eq!(doc.content[0].node_type, "mediaSingle");
6754    }
6755
6756    #[test]
6757    fn table() {
6758        let md = "| A | B |\n| --- | --- |\n| 1 | 2 |";
6759        let doc = markdown_to_adf(md).unwrap();
6760        assert_eq!(doc.content[0].node_type, "table");
6761        let rows = doc.content[0].content.as_ref().unwrap();
6762        assert_eq!(rows.len(), 2); // header + 1 body row
6763    }
6764
6765    // ── adf_to_markdown tests ───────────────────────────────────────
6766
6767    #[test]
6768    fn adf_paragraph_to_markdown() {
6769        let doc = AdfDocument {
6770            version: 1,
6771            doc_type: "doc".to_string(),
6772            content: vec![AdfNode::paragraph(vec![AdfNode::text("Hello world")])],
6773        };
6774        let md = adf_to_markdown(&doc).unwrap();
6775        assert_eq!(md.trim(), "Hello world");
6776    }
6777
6778    #[test]
6779    fn adf_heading_to_markdown() {
6780        let doc = AdfDocument {
6781            version: 1,
6782            doc_type: "doc".to_string(),
6783            content: vec![AdfNode::heading(2, vec![AdfNode::text("Title")])],
6784        };
6785        let md = adf_to_markdown(&doc).unwrap();
6786        assert_eq!(md.trim(), "## Title");
6787    }
6788
6789    #[test]
6790    fn adf_bold_to_markdown() {
6791        let doc = AdfDocument {
6792            version: 1,
6793            doc_type: "doc".to_string(),
6794            content: vec![AdfNode::paragraph(vec![
6795                AdfNode::text("Normal "),
6796                AdfNode::text_with_marks("bold", vec![AdfMark::strong()]),
6797                AdfNode::text(" text"),
6798            ])],
6799        };
6800        let md = adf_to_markdown(&doc).unwrap();
6801        assert_eq!(md.trim(), "Normal **bold** text");
6802    }
6803
6804    #[test]
6805    fn adf_code_block_to_markdown() {
6806        let doc = AdfDocument {
6807            version: 1,
6808            doc_type: "doc".to_string(),
6809            content: vec![AdfNode::code_block(Some("rust"), "let x = 1;")],
6810        };
6811        let md = adf_to_markdown(&doc).unwrap();
6812        assert!(md.contains("```rust"));
6813        assert!(md.contains("let x = 1;"));
6814        assert!(md.contains("```"));
6815    }
6816
6817    #[test]
6818    fn adf_rule_to_markdown() {
6819        let doc = AdfDocument {
6820            version: 1,
6821            doc_type: "doc".to_string(),
6822            content: vec![AdfNode::rule()],
6823        };
6824        let md = adf_to_markdown(&doc).unwrap();
6825        assert!(md.contains("---"));
6826    }
6827
6828    #[test]
6829    fn adf_bullet_list_to_markdown() {
6830        let doc = AdfDocument {
6831            version: 1,
6832            doc_type: "doc".to_string(),
6833            content: vec![AdfNode::bullet_list(vec![
6834                AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("A")])]),
6835                AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("B")])]),
6836            ])],
6837        };
6838        let md = adf_to_markdown(&doc).unwrap();
6839        assert!(md.contains("- A"));
6840        assert!(md.contains("- B"));
6841    }
6842
6843    #[test]
6844    fn adf_link_to_markdown() {
6845        let doc = AdfDocument {
6846            version: 1,
6847            doc_type: "doc".to_string(),
6848            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6849                "click",
6850                vec![AdfMark::link("https://example.com")],
6851            )])],
6852        };
6853        let md = adf_to_markdown(&doc).unwrap();
6854        assert_eq!(md.trim(), "[click](https://example.com)");
6855    }
6856
6857    #[test]
6858    fn unsupported_block_preserved_as_json() {
6859        let doc = AdfDocument {
6860            version: 1,
6861            doc_type: "doc".to_string(),
6862            content: vec![AdfNode {
6863                node_type: "unknownBlock".to_string(),
6864                attrs: Some(serde_json::json!({"key": "value"})),
6865                content: None,
6866                text: None,
6867                marks: None,
6868                local_id: None,
6869                parameters: None,
6870            }],
6871        };
6872        let md = adf_to_markdown(&doc).unwrap();
6873        assert!(md.contains("```adf-unsupported"));
6874        assert!(md.contains("\"unknownBlock\""));
6875    }
6876
6877    #[test]
6878    fn unsupported_block_round_trips() {
6879        let original = AdfDocument {
6880            version: 1,
6881            doc_type: "doc".to_string(),
6882            content: vec![AdfNode {
6883                node_type: "unknownBlock".to_string(),
6884                attrs: Some(serde_json::json!({"key": "value"})),
6885                content: None,
6886                text: None,
6887                marks: None,
6888                local_id: None,
6889                parameters: None,
6890            }],
6891        };
6892        let md = adf_to_markdown(&original).unwrap();
6893        let restored = markdown_to_adf(&md).unwrap();
6894        assert_eq!(restored.content[0].node_type, "unknownBlock");
6895        assert_eq!(restored.content[0].attrs.as_ref().unwrap()["key"], "value");
6896    }
6897
6898    // ── Round-trip tests ────────────────────────────────────────────
6899
6900    #[test]
6901    fn round_trip_simple_document() {
6902        let md = "# Hello\n\nSome text with **bold** and *italic*.\n\n- Item 1\n- Item 2\n";
6903        let adf = markdown_to_adf(md).unwrap();
6904        let restored = adf_to_markdown(&adf).unwrap();
6905
6906        assert!(restored.contains("# Hello"));
6907        assert!(restored.contains("**bold**"));
6908        assert!(restored.contains("*italic*"));
6909        assert!(restored.contains("- Item 1"));
6910        assert!(restored.contains("- Item 2"));
6911    }
6912
6913    #[test]
6914    fn round_trip_code_block() {
6915        let md = "```python\nprint('hello')\n```\n";
6916        let adf = markdown_to_adf(md).unwrap();
6917        let restored = adf_to_markdown(&adf).unwrap();
6918
6919        assert!(restored.contains("```python"));
6920        assert!(restored.contains("print('hello')"));
6921    }
6922
6923    #[test]
6924    fn round_trip_code_block_no_attrs() {
6925        let adf_json = r#"{"version":1,"type":"doc","content":[
6926            {"type":"codeBlock","content":[{"type":"text","text":"plain code"}]}
6927        ]}"#;
6928        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
6929        assert!(doc.content[0].attrs.is_none());
6930        let md = adf_to_markdown(&doc).unwrap();
6931        let round_tripped = markdown_to_adf(&md).unwrap();
6932        assert!(round_tripped.content[0].attrs.is_none());
6933    }
6934
6935    #[test]
6936    fn round_trip_code_block_empty_language() {
6937        let adf_json = r#"{"version":1,"type":"doc","content":[
6938            {"type":"codeBlock","attrs":{"language":""},"content":[{"type":"text","text":"simple code block no backtick"}]}
6939        ]}"#;
6940        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
6941        let attrs = doc.content[0].attrs.as_ref().unwrap();
6942        assert_eq!(attrs["language"], "");
6943        let md = adf_to_markdown(&doc).unwrap();
6944        let round_tripped = markdown_to_adf(&md).unwrap();
6945        let rt_attrs = round_tripped.content[0].attrs.as_ref().unwrap();
6946        assert_eq!(rt_attrs["language"], "");
6947    }
6948
6949    #[test]
6950    fn round_trip_code_block_with_language() {
6951        let adf_json = r#"{"version":1,"type":"doc","content":[
6952            {"type":"codeBlock","attrs":{"language":"python"},"content":[{"type":"text","text":"print('hi')"}]}
6953        ]}"#;
6954        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
6955        let md = adf_to_markdown(&doc).unwrap();
6956        let round_tripped = markdown_to_adf(&md).unwrap();
6957        let rt_attrs = round_tripped.content[0].attrs.as_ref().unwrap();
6958        assert_eq!(rt_attrs["language"], "python");
6959    }
6960
6961    #[test]
6962    fn multiple_paragraphs() {
6963        let md = "First paragraph.\n\nSecond paragraph.\n";
6964        let adf = markdown_to_adf(md).unwrap();
6965        assert_eq!(adf.content.len(), 2);
6966        assert_eq!(adf.content[0].node_type, "paragraph");
6967        assert_eq!(adf.content[1].node_type, "paragraph");
6968    }
6969
6970    // ── Additional markdown_to_adf tests ───────────────────────────────
6971
6972    #[test]
6973    fn horizontal_rule_underscores() {
6974        let doc = markdown_to_adf("___").unwrap();
6975        assert_eq!(doc.content[0].node_type, "rule");
6976    }
6977
6978    #[test]
6979    fn not_a_horizontal_rule_too_short() {
6980        let doc = markdown_to_adf("--").unwrap();
6981        assert_eq!(doc.content[0].node_type, "paragraph");
6982    }
6983
6984    #[test]
6985    fn bullet_list_star_marker() {
6986        let md = "* Apple\n* Banana";
6987        let doc = markdown_to_adf(md).unwrap();
6988        assert_eq!(doc.content[0].node_type, "bulletList");
6989        let items = doc.content[0].content.as_ref().unwrap();
6990        assert_eq!(items.len(), 2);
6991    }
6992
6993    #[test]
6994    fn bullet_list_plus_marker() {
6995        let md = "+ One\n+ Two";
6996        let doc = markdown_to_adf(md).unwrap();
6997        assert_eq!(doc.content[0].node_type, "bulletList");
6998    }
6999
7000    #[test]
7001    fn ordered_list_non_one_start() {
7002        let md = "5. Fifth\n6. Sixth";
7003        let doc = markdown_to_adf(md).unwrap();
7004        let node = &doc.content[0];
7005        assert_eq!(node.node_type, "orderedList");
7006        let attrs = node.attrs.as_ref().unwrap();
7007        assert_eq!(attrs["order"], 5);
7008    }
7009
7010    #[test]
7011    fn ordered_list_start_at_one_omits_order_attr() {
7012        // Issue #547: order=1 is the default and must be omitted from attrs
7013        // so that ADF→JFM→ADF round-trip is byte-identical for the common
7014        // case where the source ADF has no attrs object on orderedList.
7015        let md = "1. First\n2. Second";
7016        let doc = markdown_to_adf(md).unwrap();
7017        let node = &doc.content[0];
7018        assert_eq!(node.node_type, "orderedList");
7019        assert!(
7020            node.attrs.is_none(),
7021            "attrs should be omitted when order=1, got: {:?}",
7022            node.attrs
7023        );
7024    }
7025
7026    #[test]
7027    fn blockquote_bare_marker() {
7028        // ">" with no space after
7029        let md = ">quoted text";
7030        let doc = markdown_to_adf(md).unwrap();
7031        assert_eq!(doc.content[0].node_type, "blockquote");
7032    }
7033
7034    #[test]
7035    fn image_no_alt() {
7036        let md = "![](https://example.com/img.png)";
7037        let doc = markdown_to_adf(md).unwrap();
7038        let node = &doc.content[0];
7039        assert_eq!(node.node_type, "mediaSingle");
7040        // media child should have no alt attr
7041        let media = &node.content.as_ref().unwrap()[0];
7042        let attrs = media.attrs.as_ref().unwrap();
7043        assert!(attrs.get("alt").is_none());
7044    }
7045
7046    #[test]
7047    fn image_with_alt() {
7048        let md = "![A photo](https://example.com/photo.jpg)";
7049        let doc = markdown_to_adf(md).unwrap();
7050        let media = &doc.content[0].content.as_ref().unwrap()[0];
7051        let attrs = media.attrs.as_ref().unwrap();
7052        assert_eq!(attrs["alt"], "A photo");
7053    }
7054
7055    #[test]
7056    fn table_multi_body_rows() {
7057        let md = "| H1 | H2 |\n| --- | --- |\n| a | b |\n| c | d |";
7058        let doc = markdown_to_adf(md).unwrap();
7059        let rows = doc.content[0].content.as_ref().unwrap();
7060        assert_eq!(rows.len(), 3); // header + 2 body rows
7061                                   // First row cells are tableHeader
7062        let header_cells = rows[0].content.as_ref().unwrap();
7063        assert_eq!(header_cells[0].node_type, "tableHeader");
7064        // Body row cells are tableCell
7065        let body_cells = rows[1].content.as_ref().unwrap();
7066        assert_eq!(body_cells[0].node_type, "tableCell");
7067    }
7068
7069    #[test]
7070    fn table_no_separator_is_not_table() {
7071        // Pipe characters without a separator row should not parse as table
7072        let md = "| not | a table |";
7073        let doc = markdown_to_adf(md).unwrap();
7074        assert_eq!(doc.content[0].node_type, "paragraph");
7075    }
7076
7077    #[test]
7078    fn inline_underscore_bold() {
7079        let doc = markdown_to_adf("Some __bold__ text").unwrap();
7080        let content = doc.content[0].content.as_ref().unwrap();
7081        let bold_node = &content[1];
7082        assert_eq!(bold_node.text.as_deref(), Some("bold"));
7083        let marks = bold_node.marks.as_ref().unwrap();
7084        assert_eq!(marks[0].mark_type, "strong");
7085    }
7086
7087    #[test]
7088    fn inline_underscore_italic() {
7089        let doc = markdown_to_adf("Some _italic_ text").unwrap();
7090        let content = doc.content[0].content.as_ref().unwrap();
7091        let italic_node = &content[1];
7092        assert_eq!(italic_node.text.as_deref(), Some("italic"));
7093        let marks = italic_node.marks.as_ref().unwrap();
7094        assert_eq!(marks[0].mark_type, "em");
7095    }
7096
7097    #[test]
7098    fn intraword_underscore_not_emphasis() {
7099        // Single intraword underscore pair: do_something_useful
7100        let doc = markdown_to_adf("call do_something_useful now").unwrap();
7101        let content = doc.content[0].content.as_ref().unwrap();
7102        assert_eq!(content.len(), 1, "should be a single text node");
7103        assert_eq!(
7104            content[0].text.as_deref(),
7105            Some("call do_something_useful now")
7106        );
7107        assert!(content[0].marks.is_none());
7108    }
7109
7110    #[test]
7111    fn intraword_underscore_multiple() {
7112        // Multiple intraword underscores: a_b_c_d
7113        let doc = markdown_to_adf("use a_b_c_d here").unwrap();
7114        let content = doc.content[0].content.as_ref().unwrap();
7115        assert_eq!(content.len(), 1);
7116        assert_eq!(content[0].text.as_deref(), Some("use a_b_c_d here"));
7117        assert!(content[0].marks.is_none());
7118    }
7119
7120    #[test]
7121    fn intraword_double_underscore_not_bold() {
7122        // Intraword double underscores: foo__bar__baz
7123        let doc = markdown_to_adf("foo__bar__baz").unwrap();
7124        let content = doc.content[0].content.as_ref().unwrap();
7125        assert_eq!(content.len(), 1);
7126        assert_eq!(content[0].text.as_deref(), Some("foo__bar__baz"));
7127        assert!(content[0].marks.is_none());
7128    }
7129
7130    #[test]
7131    fn intraword_triple_underscore_not_bold_italic() {
7132        // Intraword triple underscores: x___y___z
7133        let doc = markdown_to_adf("x___y___z").unwrap();
7134        let content = doc.content[0].content.as_ref().unwrap();
7135        assert_eq!(content.len(), 1);
7136        assert_eq!(content[0].text.as_deref(), Some("x___y___z"));
7137        assert!(content[0].marks.is_none());
7138    }
7139
7140    #[test]
7141    fn underscore_emphasis_still_works_with_spaces() {
7142        // Normal emphasis with spaces around delimiters should still work
7143        let doc = markdown_to_adf("some _italic_ here").unwrap();
7144        let content = doc.content[0].content.as_ref().unwrap();
7145        assert_eq!(content.len(), 3);
7146        assert_eq!(content[1].text.as_deref(), Some("italic"));
7147        let marks = content[1].marks.as_ref().unwrap();
7148        assert_eq!(marks[0].mark_type, "em");
7149    }
7150
7151    #[test]
7152    fn underscore_bold_still_works_with_spaces() {
7153        // Normal bold with spaces around delimiters should still work
7154        let doc = markdown_to_adf("some __bold__ here").unwrap();
7155        let content = doc.content[0].content.as_ref().unwrap();
7156        assert_eq!(content.len(), 3);
7157        assert_eq!(content[1].text.as_deref(), Some("bold"));
7158        let marks = content[1].marks.as_ref().unwrap();
7159        assert_eq!(marks[0].mark_type, "strong");
7160    }
7161
7162    #[test]
7163    fn intraword_underscore_closing_only() {
7164        // Opening _ is valid (preceded by space) but closing _ is intraword: _foo_bar
7165        let doc = markdown_to_adf("_foo_bar").unwrap();
7166        let content = doc.content[0].content.as_ref().unwrap();
7167        assert_eq!(content.len(), 1);
7168        assert_eq!(content[0].text.as_deref(), Some("_foo_bar"));
7169    }
7170
7171    #[test]
7172    fn intraword_double_underscore_closing_only() {
7173        // Opening __ is valid (at start) but closing __ is intraword: __foo__bar
7174        let doc = markdown_to_adf("__foo__bar").unwrap();
7175        let content = doc.content[0].content.as_ref().unwrap();
7176        assert_eq!(content.len(), 1);
7177        assert_eq!(content[0].text.as_deref(), Some("__foo__bar"));
7178    }
7179
7180    #[test]
7181    fn intraword_triple_underscore_closing_only() {
7182        // Opening ___ is valid (at start) but closing ___ is intraword: ___foo___bar
7183        let doc = markdown_to_adf("___foo___bar").unwrap();
7184        let content = doc.content[0].content.as_ref().unwrap();
7185        assert_eq!(content.len(), 1);
7186        assert_eq!(content[0].text.as_deref(), Some("___foo___bar"));
7187    }
7188
7189    #[test]
7190    fn asterisk_emphasis_unaffected_by_intraword_fix() {
7191        // Asterisks should still work for intraword emphasis (CommonMark allows this)
7192        let doc = markdown_to_adf("foo*bar*baz").unwrap();
7193        let content = doc.content[0].content.as_ref().unwrap();
7194        // Asterisks CAN be intraword emphasis per CommonMark
7195        assert!(content.len() > 1 || content[0].marks.is_some());
7196    }
7197
7198    #[test]
7199    fn intraword_underscore_at_start_of_text() {
7200        // Underscore at start of text is not intraword (no preceding alphanumeric)
7201        let doc = markdown_to_adf("_italic_ word").unwrap();
7202        let content = doc.content[0].content.as_ref().unwrap();
7203        assert_eq!(content[0].text.as_deref(), Some("italic"));
7204        let marks = content[0].marks.as_ref().unwrap();
7205        assert_eq!(marks[0].mark_type, "em");
7206    }
7207
7208    #[test]
7209    fn intraword_underscore_at_end_of_text() {
7210        // Underscore at end of text is not intraword (no following alphanumeric)
7211        let doc = markdown_to_adf("word _italic_").unwrap();
7212        let content = doc.content[0].content.as_ref().unwrap();
7213        let last = content.last().unwrap();
7214        assert_eq!(last.text.as_deref(), Some("italic"));
7215        let marks = last.marks.as_ref().unwrap();
7216        assert_eq!(marks[0].mark_type, "em");
7217    }
7218
7219    #[test]
7220    fn intraword_underscore_opening_only() {
7221        // Opening underscore is intraword but closing is not: a_b c_d
7222        // The first _ is intraword (a_b), so it shouldn't open emphasis
7223        let doc = markdown_to_adf("a_b c_d").unwrap();
7224        let content = doc.content[0].content.as_ref().unwrap();
7225        assert_eq!(content.len(), 1);
7226        assert_eq!(content[0].text.as_deref(), Some("a_b c_d"));
7227    }
7228
7229    #[test]
7230    fn intraword_underscore_roundtrip() {
7231        // The original reproducer from issue #438
7232        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"call the do_something_useful function"}]}]}"#;
7233        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7234        let jfm = adf_to_markdown(&adf).unwrap();
7235        let roundtripped = markdown_to_adf(&jfm).unwrap();
7236        let content = roundtripped.content[0].content.as_ref().unwrap();
7237        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7238        assert_eq!(
7239            content[0].text.as_deref(),
7240            Some("call the do_something_useful function")
7241        );
7242        assert!(content[0].marks.is_none());
7243    }
7244
7245    #[test]
7246    fn asterisk_emphasis_roundtrip() {
7247        // The original reproducer from issue #452
7248        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Status: *confirmed* and active"}]}]}"#;
7249        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7250        let jfm = adf_to_markdown(&adf).unwrap();
7251        let roundtripped = markdown_to_adf(&jfm).unwrap();
7252        let content = roundtripped.content[0].content.as_ref().unwrap();
7253        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7254        assert_eq!(
7255            content[0].text.as_deref(),
7256            Some("Status: *confirmed* and active")
7257        );
7258        assert!(content[0].marks.is_none());
7259    }
7260
7261    #[test]
7262    fn double_asterisk_roundtrip() {
7263        // **bold** delimiters in plain text should not become strong marks
7264        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use **kwargs in Python"}]}]}"#;
7265        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7266        let jfm = adf_to_markdown(&adf).unwrap();
7267        let roundtripped = markdown_to_adf(&jfm).unwrap();
7268        let content = roundtripped.content[0].content.as_ref().unwrap();
7269        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7270        assert_eq!(content[0].text.as_deref(), Some("Use **kwargs in Python"));
7271        assert!(content[0].marks.is_none());
7272    }
7273
7274    #[test]
7275    fn asterisk_with_em_mark_roundtrip() {
7276        // Text that already has an em mark should preserve both the mark and escaped content
7277        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a*b","marks":[{"type":"em"}]}]}]}"#;
7278        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7279        let jfm = adf_to_markdown(&adf).unwrap();
7280        let roundtripped = markdown_to_adf(&jfm).unwrap();
7281        let content = roundtripped.content[0].content.as_ref().unwrap();
7282        // Find the node with em mark
7283        let em_node = content.iter().find(|n| {
7284            n.marks
7285                .as_ref()
7286                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "em"))
7287        });
7288        assert!(em_node.is_some(), "should have an em-marked node");
7289        assert_eq!(em_node.unwrap().text.as_deref(), Some("a*b"));
7290    }
7291
7292    #[test]
7293    fn lone_asterisk_roundtrip() {
7294        // A single asterisk that cannot form emphasis should round-trip
7295        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"rating: 5 * stars"}]}]}"#;
7296        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7297        let jfm = adf_to_markdown(&adf).unwrap();
7298        let roundtripped = markdown_to_adf(&jfm).unwrap();
7299        let content = roundtripped.content[0].content.as_ref().unwrap();
7300        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7301        assert_eq!(content[0].text.as_deref(), Some("rating: 5 * stars"));
7302    }
7303
7304    #[test]
7305    fn escape_emphasis_markers_unit() {
7306        assert_eq!(escape_emphasis_markers("hello"), "hello");
7307        assert_eq!(escape_emphasis_markers("*bold*"), r"\*bold\*");
7308        assert_eq!(escape_emphasis_markers("**strong**"), r"\*\*strong\*\*");
7309        assert_eq!(escape_emphasis_markers("no stars"), "no stars");
7310        assert_eq!(escape_emphasis_markers("a * b"), r"a \* b");
7311        assert_eq!(escape_emphasis_markers(""), "");
7312    }
7313
7314    #[test]
7315    fn escape_emphasis_markers_underscore_intraword() {
7316        // Intraword underscores (alnum on both sides within the node) are
7317        // left as-is — the JFM parser will reject them as emphasis.
7318        assert_eq!(escape_emphasis_markers("foo_bar"), "foo_bar");
7319        assert_eq!(escape_emphasis_markers("a_b_c"), "a_b_c");
7320        assert_eq!(escape_emphasis_markers("foo__bar"), "foo__bar");
7321        assert_eq!(
7322            escape_emphasis_markers("call do_something_useful"),
7323            "call do_something_useful"
7324        );
7325    }
7326
7327    #[test]
7328    fn escape_emphasis_markers_underscore_at_boundary() {
7329        // Leading and trailing underscores get escaped — adjacent text nodes
7330        // could supply the alphanumeric needed to close emphasis (issue #554).
7331        assert_eq!(escape_emphasis_markers("_Action"), r"\_Action");
7332        assert_eq!(escape_emphasis_markers("Action_"), r"Action\_");
7333        assert_eq!(escape_emphasis_markers("_ "), r"\_ ");
7334        assert_eq!(escape_emphasis_markers(" _"), r" \_");
7335        assert_eq!(escape_emphasis_markers("_"), r"\_");
7336    }
7337
7338    #[test]
7339    fn escape_emphasis_markers_underscore_with_punctuation() {
7340        // Underscores adjacent to punctuation (not alphanumeric) get escaped.
7341        assert_eq!(escape_emphasis_markers("foo _bar"), r"foo \_bar");
7342        assert_eq!(escape_emphasis_markers("foo_ bar"), r"foo\_ bar");
7343        assert_eq!(escape_emphasis_markers("(_x_)"), r"(\_x\_)");
7344    }
7345
7346    #[test]
7347    fn find_unescaped_skips_backslash_escaped() {
7348        // Escaped `**` should not be found
7349        assert_eq!(find_unescaped(r"a\*\*b**", "**"), Some(6));
7350        // No unescaped match at all
7351        assert_eq!(find_unescaped(r"a\*\*b", "**"), None);
7352        // Plain match without any escaping
7353        assert_eq!(find_unescaped("a**b", "**"), Some(1));
7354        // Empty haystack
7355        assert_eq!(find_unescaped("", "**"), None);
7356    }
7357
7358    #[test]
7359    fn find_unescaped_char_skips_backslash_escaped() {
7360        // Escaped `*` should not be found
7361        assert_eq!(find_unescaped_char(r"a\*b*", b'*'), Some(4));
7362        // No unescaped match at all
7363        assert_eq!(find_unescaped_char(r"\*", b'*'), None);
7364        // Plain match
7365        assert_eq!(find_unescaped_char("a*b", b'*'), Some(1));
7366        // Empty haystack
7367        assert_eq!(find_unescaped_char("", b'*'), None);
7368    }
7369
7370    #[test]
7371    fn double_asterisk_in_strong_mark_roundtrip() {
7372        // Text with ** inside a strong mark should preserve the literal **
7373        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"call **kwargs","marks":[{"type":"strong"}]}]}]}"#;
7374        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7375        let jfm = adf_to_markdown(&adf).unwrap();
7376        let roundtripped = markdown_to_adf(&jfm).unwrap();
7377        let content = roundtripped.content[0].content.as_ref().unwrap();
7378        let strong_node = content.iter().find(|n| {
7379            n.marks
7380                .as_ref()
7381                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
7382        });
7383        assert!(strong_node.is_some(), "should have a strong-marked node");
7384        assert_eq!(strong_node.unwrap().text.as_deref(), Some("call **kwargs"));
7385    }
7386
7387    #[test]
7388    fn backtick_code_roundtrip() {
7389        // The original reproducer from issue #453
7390        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Set `max_retries` to 3 in the config"}]}]}"#;
7391        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7392        let jfm = adf_to_markdown(&adf).unwrap();
7393        let roundtripped = markdown_to_adf(&jfm).unwrap();
7394        let content = roundtripped.content[0].content.as_ref().unwrap();
7395        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7396        assert_eq!(
7397            content[0].text.as_deref(),
7398            Some("Set `max_retries` to 3 in the config")
7399        );
7400        assert!(content[0].marks.is_none());
7401    }
7402
7403    #[test]
7404    fn multiple_backtick_spans_roundtrip() {
7405        // Multiple backtick-delimited spans in a single text node
7406        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use `foo` and `bar` together"}]}]}"#;
7407        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7408        let jfm = adf_to_markdown(&adf).unwrap();
7409        let roundtripped = markdown_to_adf(&jfm).unwrap();
7410        let content = roundtripped.content[0].content.as_ref().unwrap();
7411        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7412        assert_eq!(
7413            content[0].text.as_deref(),
7414            Some("Use `foo` and `bar` together")
7415        );
7416        assert!(content[0].marks.is_none());
7417    }
7418
7419    #[test]
7420    fn lone_backtick_roundtrip() {
7421        // A single backtick that cannot form a code span should round-trip
7422        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use a ` character"}]}]}"#;
7423        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7424        let jfm = adf_to_markdown(&adf).unwrap();
7425        let roundtripped = markdown_to_adf(&jfm).unwrap();
7426        let content = roundtripped.content[0].content.as_ref().unwrap();
7427        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7428        assert_eq!(content[0].text.as_deref(), Some("Use a ` character"));
7429        assert!(content[0].marks.is_none());
7430    }
7431
7432    #[test]
7433    fn backtick_with_code_mark_roundtrip() {
7434        // Text that already has a code mark should preserve both the mark and content
7435        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"max_retries","marks":[{"type":"code"}]}]}]}"#;
7436        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7437        let jfm = adf_to_markdown(&adf).unwrap();
7438        assert_eq!(jfm.trim(), "`max_retries`");
7439        let roundtripped = markdown_to_adf(&jfm).unwrap();
7440        let content = roundtripped.content[0].content.as_ref().unwrap();
7441        let code_node = content.iter().find(|n| {
7442            n.marks
7443                .as_ref()
7444                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code"))
7445        });
7446        assert!(code_node.is_some(), "should have a code-marked node");
7447        assert_eq!(code_node.unwrap().text.as_deref(), Some("max_retries"));
7448    }
7449
7450    #[test]
7451    fn backtick_with_em_mark_roundtrip() {
7452        // Backtick inside em-marked text should preserve both
7453        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"use `cfg`","marks":[{"type":"em"}]}]}]}"#;
7454        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7455        let jfm = adf_to_markdown(&adf).unwrap();
7456        let roundtripped = markdown_to_adf(&jfm).unwrap();
7457        let content = roundtripped.content[0].content.as_ref().unwrap();
7458        let em_node = content.iter().find(|n| {
7459            n.marks
7460                .as_ref()
7461                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "em"))
7462        });
7463        assert!(em_node.is_some(), "should have an em-marked node");
7464        assert_eq!(em_node.unwrap().text.as_deref(), Some("use `cfg`"));
7465    }
7466
7467    #[test]
7468    fn escape_pipes_in_cell_unit() {
7469        assert_eq!(escape_pipes_in_cell("hello"), "hello");
7470        assert_eq!(escape_pipes_in_cell("a|b"), r"a\|b");
7471        assert_eq!(escape_pipes_in_cell("|"), r"\|");
7472        assert_eq!(escape_pipes_in_cell("|a|b|"), r"\|a\|b\|");
7473        assert_eq!(escape_pipes_in_cell(""), "");
7474        assert_eq!(
7475            escape_pipes_in_cell("`parser.decode[T|json]`"),
7476            r"`parser.decode[T\|json]`"
7477        );
7478    }
7479
7480    #[test]
7481    fn escape_backticks_unit() {
7482        assert_eq!(escape_backticks("hello"), "hello");
7483        assert_eq!(escape_backticks("`code`"), r"\`code\`");
7484        assert_eq!(escape_backticks("no ticks"), "no ticks");
7485        assert_eq!(escape_backticks("a ` b"), r"a \` b");
7486        assert_eq!(escape_backticks(""), "");
7487        assert_eq!(escape_backticks("`a` and `b`"), r"\`a\` and \`b\`");
7488    }
7489
7490    // ── Issue #477: backslash escaping ──────────────────────────────
7491
7492    #[test]
7493    fn backslash_in_text_roundtrip() {
7494        // The original reproducer from issue #477
7495        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"The path is C:\\Users\\admin\\file.txt"}]}]}"#;
7496        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7497        let jfm = adf_to_markdown(&adf).unwrap();
7498        let roundtripped = markdown_to_adf(&jfm).unwrap();
7499        let content = roundtripped.content[0].content.as_ref().unwrap();
7500        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7501        assert_eq!(
7502            content[0].text.as_deref(),
7503            Some(r"The path is C:\Users\admin\file.txt")
7504        );
7505    }
7506
7507    #[test]
7508    fn backslash_emitted_as_double_backslash() {
7509        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a\\b"}]}]}"#;
7510        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7511        let jfm = adf_to_markdown(&adf).unwrap();
7512        assert!(
7513            jfm.contains(r"a\\b"),
7514            "JFM should contain escaped backslash: {jfm}"
7515        );
7516    }
7517
7518    #[test]
7519    fn consecutive_backslashes_roundtrip() {
7520        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a\\\\b"}]}]}"#;
7521        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7522        let jfm = adf_to_markdown(&adf).unwrap();
7523        let roundtripped = markdown_to_adf(&jfm).unwrap();
7524        let content = roundtripped.content[0].content.as_ref().unwrap();
7525        assert_eq!(
7526            content[0].text.as_deref(),
7527            Some(r"a\\b"),
7528            "consecutive backslashes should survive round-trip"
7529        );
7530    }
7531
7532    #[test]
7533    fn backslash_with_strong_mark_roundtrip() {
7534        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\Users","marks":[{"type":"strong"}]}]}]}"#;
7535        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7536        let jfm = adf_to_markdown(&adf).unwrap();
7537        let roundtripped = markdown_to_adf(&jfm).unwrap();
7538        let content = roundtripped.content[0].content.as_ref().unwrap();
7539        let strong_node = content.iter().find(|n| {
7540            n.marks
7541                .as_ref()
7542                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
7543        });
7544        assert!(strong_node.is_some(), "should have a strong-marked node");
7545        assert_eq!(strong_node.unwrap().text.as_deref(), Some(r"C:\Users"));
7546    }
7547
7548    #[test]
7549    fn backslash_with_code_mark_not_escaped() {
7550        // Code marks emit content verbatim — backslashes should NOT be escaped
7551        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\Users","marks":[{"type":"code"}]}]}]}"#;
7552        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7553        let jfm = adf_to_markdown(&adf).unwrap();
7554        assert_eq!(jfm.trim(), r"`C:\Users`");
7555        let roundtripped = markdown_to_adf(&jfm).unwrap();
7556        let content = roundtripped.content[0].content.as_ref().unwrap();
7557        let code_node = content.iter().find(|n| {
7558            n.marks
7559                .as_ref()
7560                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code"))
7561        });
7562        assert!(code_node.is_some(), "should have a code-marked node");
7563        assert_eq!(code_node.unwrap().text.as_deref(), Some(r"C:\Users"));
7564    }
7565
7566    #[test]
7567    fn backslash_before_special_chars_roundtrip() {
7568        // Backslash before characters that are themselves escaped (* ` :)
7569        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"\\*not bold\\*"}]}]}"#;
7570        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7571        let jfm = adf_to_markdown(&adf).unwrap();
7572        let roundtripped = markdown_to_adf(&jfm).unwrap();
7573        let content = roundtripped.content[0].content.as_ref().unwrap();
7574        assert_eq!(
7575            content[0].text.as_deref(),
7576            Some(r"\*not bold\*"),
7577            "backslash before special char should survive round-trip"
7578        );
7579    }
7580
7581    #[test]
7582    fn backslash_and_newline_in_text_roundtrip() {
7583        // Text with both backslashes and embedded newlines
7584        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\path\nline2"}]}]}"#;
7585        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7586        let jfm = adf_to_markdown(&adf).unwrap();
7587        let roundtripped = markdown_to_adf(&jfm).unwrap();
7588        let content = roundtripped.content[0].content.as_ref().unwrap();
7589        assert_eq!(
7590            content[0].text.as_deref(),
7591            Some("C:\\path\nline2"),
7592            "backslash and newline should both survive round-trip"
7593        );
7594    }
7595
7596    #[test]
7597    fn lone_backslash_roundtrip() {
7598        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a \\ b"}]}]}"#;
7599        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7600        let jfm = adf_to_markdown(&adf).unwrap();
7601        let roundtripped = markdown_to_adf(&jfm).unwrap();
7602        let content = roundtripped.content[0].content.as_ref().unwrap();
7603        assert_eq!(content[0].text.as_deref(), Some(r"a \ b"));
7604    }
7605
7606    #[test]
7607    fn trailing_backslash_in_text_roundtrip() {
7608        // A trailing backslash in text content (not a hardBreak) should round-trip
7609        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"end\\"}]}]}"#;
7610        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7611        let jfm = adf_to_markdown(&adf).unwrap();
7612        let roundtripped = markdown_to_adf(&jfm).unwrap();
7613        let content = roundtripped.content[0].content.as_ref().unwrap();
7614        assert_eq!(
7615            content[0].text.as_deref(),
7616            Some(r"end\"),
7617            "trailing backslash should survive round-trip"
7618        );
7619    }
7620
7621    #[test]
7622    fn escape_bare_urls_unit() {
7623        assert_eq!(escape_bare_urls("hello"), "hello");
7624        assert_eq!(escape_bare_urls(""), "");
7625        assert_eq!(
7626            escape_bare_urls("https://example.com"),
7627            r"\https://example.com"
7628        );
7629        assert_eq!(
7630            escape_bare_urls("http://example.com"),
7631            r"\http://example.com"
7632        );
7633        assert_eq!(
7634            escape_bare_urls("see https://a.com and https://b.com"),
7635            r"see \https://a.com and \https://b.com"
7636        );
7637        // "http" without "://" is not a URL scheme — leave untouched
7638        assert_eq!(escape_bare_urls("http header"), "http header");
7639        assert_eq!(escape_bare_urls("https is secure"), "https is secure");
7640    }
7641
7642    #[test]
7643    fn heading_not_valid_without_space() {
7644        // "#Title" without space should be a paragraph, not heading
7645        let doc = markdown_to_adf("#Title").unwrap();
7646        assert_eq!(doc.content[0].node_type, "paragraph");
7647    }
7648
7649    #[test]
7650    fn heading_level_too_high() {
7651        // ####### (7 hashes) is not a valid heading
7652        let doc = markdown_to_adf("####### Not a heading").unwrap();
7653        assert_eq!(doc.content[0].node_type, "paragraph");
7654    }
7655
7656    #[test]
7657    fn empty_document() {
7658        let doc = markdown_to_adf("").unwrap();
7659        assert!(doc.content.is_empty());
7660    }
7661
7662    #[test]
7663    fn only_blank_lines() {
7664        let doc = markdown_to_adf("\n\n\n").unwrap();
7665        assert!(doc.content.is_empty());
7666    }
7667
7668    #[test]
7669    fn code_block_unterminated() {
7670        // Code block without closing fence
7671        let md = "```rust\nfn main() {}";
7672        let doc = markdown_to_adf(md).unwrap();
7673        assert_eq!(doc.content[0].node_type, "codeBlock");
7674    }
7675
7676    #[test]
7677    fn mixed_document() {
7678        let md = "# Title\n\nA paragraph.\n\n- Item\n\n```\ncode\n```\n\n> quote\n\n---\n\n1. numbered\n";
7679        let doc = markdown_to_adf(md).unwrap();
7680        let types: Vec<&str> = doc.content.iter().map(|n| n.node_type.as_str()).collect();
7681        assert_eq!(
7682            types,
7683            vec![
7684                "heading",
7685                "paragraph",
7686                "bulletList",
7687                "codeBlock",
7688                "blockquote",
7689                "rule",
7690                "orderedList",
7691            ]
7692        );
7693    }
7694
7695    // ── Additional adf_to_markdown tests ───────────────────────────────
7696
7697    #[test]
7698    fn adf_ordered_list_to_markdown() {
7699        let doc = AdfDocument {
7700            version: 1,
7701            doc_type: "doc".to_string(),
7702            content: vec![AdfNode::ordered_list(
7703                vec![
7704                    AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("First")])]),
7705                    AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("Second")])]),
7706                ],
7707                None,
7708            )],
7709        };
7710        let md = adf_to_markdown(&doc).unwrap();
7711        assert!(md.contains("1. First"));
7712        assert!(md.contains("2. Second"));
7713    }
7714
7715    #[test]
7716    fn adf_ordered_list_custom_start() {
7717        let doc = AdfDocument {
7718            version: 1,
7719            doc_type: "doc".to_string(),
7720            content: vec![AdfNode::ordered_list(
7721                vec![AdfNode::list_item(vec![AdfNode::paragraph(vec![
7722                    AdfNode::text("Third"),
7723                ])])],
7724                Some(3),
7725            )],
7726        };
7727        let md = adf_to_markdown(&doc).unwrap();
7728        assert!(md.contains("3. Third"));
7729    }
7730
7731    #[test]
7732    fn adf_blockquote_to_markdown() {
7733        let doc = AdfDocument {
7734            version: 1,
7735            doc_type: "doc".to_string(),
7736            content: vec![AdfNode::blockquote(vec![AdfNode::paragraph(vec![
7737                AdfNode::text("A quote"),
7738            ])])],
7739        };
7740        let md = adf_to_markdown(&doc).unwrap();
7741        assert!(md.contains("> A quote"));
7742    }
7743
7744    #[test]
7745    fn adf_table_to_markdown() {
7746        let doc = AdfDocument {
7747            version: 1,
7748            doc_type: "doc".to_string(),
7749            content: vec![AdfNode::table(vec![
7750                AdfNode::table_row(vec![
7751                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("Name")])]),
7752                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("Value")])]),
7753                ]),
7754                AdfNode::table_row(vec![
7755                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("a")])]),
7756                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("1")])]),
7757                ]),
7758            ])],
7759        };
7760        let md = adf_to_markdown(&doc).unwrap();
7761        assert!(md.contains("| Name | Value |"));
7762        assert!(md.contains("| --- | --- |"));
7763        assert!(md.contains("| a | 1 |"));
7764    }
7765
7766    #[test]
7767    fn adf_media_to_markdown() {
7768        let doc = AdfDocument {
7769            version: 1,
7770            doc_type: "doc".to_string(),
7771            content: vec![AdfNode::media_single(
7772                "https://example.com/img.png",
7773                Some("Alt"),
7774            )],
7775        };
7776        let md = adf_to_markdown(&doc).unwrap();
7777        assert!(md.contains("![Alt](https://example.com/img.png)"));
7778    }
7779
7780    #[test]
7781    fn adf_media_no_alt_to_markdown() {
7782        let doc = AdfDocument {
7783            version: 1,
7784            doc_type: "doc".to_string(),
7785            content: vec![AdfNode::media_single("https://example.com/img.png", None)],
7786        };
7787        let md = adf_to_markdown(&doc).unwrap();
7788        assert!(md.contains("![](https://example.com/img.png)"));
7789    }
7790
7791    #[test]
7792    fn adf_italic_to_markdown() {
7793        let doc = AdfDocument {
7794            version: 1,
7795            doc_type: "doc".to_string(),
7796            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7797                "emphasis",
7798                vec![AdfMark::em()],
7799            )])],
7800        };
7801        let md = adf_to_markdown(&doc).unwrap();
7802        assert_eq!(md.trim(), "*emphasis*");
7803    }
7804
7805    #[test]
7806    fn adf_strikethrough_to_markdown() {
7807        let doc = AdfDocument {
7808            version: 1,
7809            doc_type: "doc".to_string(),
7810            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7811                "deleted",
7812                vec![AdfMark::strike()],
7813            )])],
7814        };
7815        let md = adf_to_markdown(&doc).unwrap();
7816        assert_eq!(md.trim(), "~~deleted~~");
7817    }
7818
7819    #[test]
7820    fn adf_inline_code_to_markdown() {
7821        let doc = AdfDocument {
7822            version: 1,
7823            doc_type: "doc".to_string(),
7824            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7825                "code",
7826                vec![AdfMark::code()],
7827            )])],
7828        };
7829        let md = adf_to_markdown(&doc).unwrap();
7830        assert_eq!(md.trim(), "`code`");
7831    }
7832
7833    #[test]
7834    fn adf_code_with_link_to_markdown() {
7835        let doc = AdfDocument {
7836            version: 1,
7837            doc_type: "doc".to_string(),
7838            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7839                "func",
7840                vec![AdfMark::code(), AdfMark::link("https://example.com")],
7841            )])],
7842        };
7843        let md = adf_to_markdown(&doc).unwrap();
7844        assert_eq!(md.trim(), "[`func`](https://example.com)");
7845    }
7846
7847    #[test]
7848    fn adf_bold_italic_to_markdown() {
7849        let doc = AdfDocument {
7850            version: 1,
7851            doc_type: "doc".to_string(),
7852            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7853                "both",
7854                vec![AdfMark::strong(), AdfMark::em()],
7855            )])],
7856        };
7857        let md = adf_to_markdown(&doc).unwrap();
7858        assert_eq!(md.trim(), "***both***");
7859    }
7860
7861    #[test]
7862    fn adf_bold_link_to_markdown() {
7863        let doc = AdfDocument {
7864            version: 1,
7865            doc_type: "doc".to_string(),
7866            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7867                "bold link",
7868                vec![AdfMark::strong(), AdfMark::link("https://example.com")],
7869            )])],
7870        };
7871        let md = adf_to_markdown(&doc).unwrap();
7872        assert_eq!(md.trim(), "**[bold link](https://example.com)**");
7873    }
7874
7875    #[test]
7876    fn adf_strikethrough_bold_to_markdown() {
7877        let doc = AdfDocument {
7878            version: 1,
7879            doc_type: "doc".to_string(),
7880            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7881                "struck",
7882                vec![AdfMark::strike(), AdfMark::strong()],
7883            )])],
7884        };
7885        let md = adf_to_markdown(&doc).unwrap();
7886        assert_eq!(md.trim(), "~~**struck**~~");
7887    }
7888
7889    #[test]
7890    fn adf_hard_break_to_markdown() {
7891        let doc = AdfDocument {
7892            version: 1,
7893            doc_type: "doc".to_string(),
7894            content: vec![AdfNode::paragraph(vec![
7895                AdfNode::text("Line 1"),
7896                AdfNode::hard_break(),
7897                AdfNode::text("Line 2"),
7898            ])],
7899        };
7900        let md = adf_to_markdown(&doc).unwrap();
7901        assert!(md.contains("Line 1\\\n  Line 2"));
7902    }
7903
7904    #[test]
7905    #[test]
7906    fn adf_unsupported_inline_to_markdown() {
7907        let doc = AdfDocument {
7908            version: 1,
7909            doc_type: "doc".to_string(),
7910            content: vec![AdfNode::paragraph(vec![AdfNode {
7911                node_type: "unknownInline".to_string(),
7912                attrs: None,
7913                content: None,
7914                text: None,
7915                marks: None,
7916                local_id: None,
7917                parameters: None,
7918            }])],
7919        };
7920        let md = adf_to_markdown(&doc).unwrap();
7921        assert!(md.contains("<!-- unsupported inline: unknownInline -->"));
7922    }
7923
7924    // ── mediaInline tests (issue #476) ─────────────────────────────────
7925
7926    #[test]
7927    fn adf_media_inline_to_markdown() {
7928        let doc = AdfDocument {
7929            version: 1,
7930            doc_type: "doc".to_string(),
7931            content: vec![AdfNode::paragraph(vec![
7932                AdfNode::text("see "),
7933                AdfNode::media_inline(serde_json::json!({
7934                    "type": "image",
7935                    "id": "abcdef01-2345-6789-abcd-abcdef012345",
7936                    "collection": "contentId-111111",
7937                    "width": 200,
7938                    "height": 100
7939                })),
7940                AdfNode::text(" for details"),
7941            ])],
7942        };
7943        let md = adf_to_markdown(&doc).unwrap();
7944        assert!(md.contains(":media-inline[]{"), "got: {md}");
7945        assert!(md.contains("type=image"));
7946        assert!(md.contains("id=abcdef01-2345-6789-abcd-abcdef012345"));
7947        assert!(md.contains("collection=contentId-111111"));
7948        assert!(md.contains("width=200"));
7949        assert!(md.contains("height=100"));
7950        assert!(!md.contains("<!-- unsupported inline"));
7951    }
7952
7953    #[test]
7954    fn media_inline_round_trip() {
7955        let doc = AdfDocument {
7956            version: 1,
7957            doc_type: "doc".to_string(),
7958            content: vec![AdfNode::paragraph(vec![
7959                AdfNode::text("see "),
7960                AdfNode::media_inline(serde_json::json!({
7961                    "type": "image",
7962                    "id": "abcdef01-2345-6789-abcd-abcdef012345",
7963                    "collection": "contentId-111111",
7964                    "width": 200,
7965                    "height": 100
7966                })),
7967                AdfNode::text(" for details"),
7968            ])],
7969        };
7970        let md = adf_to_markdown(&doc).unwrap();
7971        let rt = markdown_to_adf(&md).unwrap();
7972
7973        let content = rt.content[0].content.as_ref().unwrap();
7974        assert_eq!(content[0].text.as_deref(), Some("see "));
7975        assert_eq!(content[1].node_type, "mediaInline");
7976        let attrs = content[1].attrs.as_ref().unwrap();
7977        assert_eq!(attrs["type"], "image");
7978        assert_eq!(attrs["id"], "abcdef01-2345-6789-abcd-abcdef012345");
7979        assert_eq!(attrs["collection"], "contentId-111111");
7980        assert_eq!(attrs["width"], 200);
7981        assert_eq!(attrs["height"], 100);
7982        assert_eq!(content[2].text.as_deref(), Some(" for details"));
7983    }
7984
7985    #[test]
7986    fn media_inline_external_url_round_trip() {
7987        let doc = AdfDocument {
7988            version: 1,
7989            doc_type: "doc".to_string(),
7990            content: vec![AdfNode::paragraph(vec![AdfNode::media_inline(
7991                serde_json::json!({
7992                    "type": "external",
7993                    "url": "https://example.com/image.png",
7994                    "alt": "example",
7995                    "width": 400,
7996                    "height": 300
7997                }),
7998            )])],
7999        };
8000        let md = adf_to_markdown(&doc).unwrap();
8001        let rt = markdown_to_adf(&md).unwrap();
8002
8003        let content = rt.content[0].content.as_ref().unwrap();
8004        assert_eq!(content[0].node_type, "mediaInline");
8005        let attrs = content[0].attrs.as_ref().unwrap();
8006        assert_eq!(attrs["type"], "external");
8007        assert_eq!(attrs["url"], "https://example.com/image.png");
8008        assert_eq!(attrs["alt"], "example");
8009        assert_eq!(attrs["width"], 400);
8010        assert_eq!(attrs["height"], 300);
8011    }
8012
8013    #[test]
8014    fn media_inline_minimal_attrs() {
8015        let doc = AdfDocument {
8016            version: 1,
8017            doc_type: "doc".to_string(),
8018            content: vec![AdfNode::paragraph(vec![AdfNode::media_inline(
8019                serde_json::json!({"type": "file", "id": "abc-123"}),
8020            )])],
8021        };
8022        let md = adf_to_markdown(&doc).unwrap();
8023        let rt = markdown_to_adf(&md).unwrap();
8024
8025        let content = rt.content[0].content.as_ref().unwrap();
8026        assert_eq!(content[0].node_type, "mediaInline");
8027        let attrs = content[0].attrs.as_ref().unwrap();
8028        assert_eq!(attrs["type"], "file");
8029        assert_eq!(attrs["id"], "abc-123");
8030    }
8031
8032    #[test]
8033    fn media_inline_from_issue_476_reproducer() {
8034        // Exact reproducer from issue #476
8035        let adf_json: serde_json::Value = serde_json::json!({
8036            "type": "doc",
8037            "version": 1,
8038            "content": [
8039                {
8040                    "type": "paragraph",
8041                    "content": [
8042                        {"type": "text", "text": "see "},
8043                        {
8044                            "type": "mediaInline",
8045                            "attrs": {
8046                                "collection": "contentId-111111",
8047                                "height": 100,
8048                                "id": "abcdef01-2345-6789-abcd-abcdef012345",
8049                                "localId": "aabbccdd-1234-5678-abcd-aabbccdd1234",
8050                                "type": "image",
8051                                "width": 200
8052                            }
8053                        },
8054                        {"type": "text", "text": " for details"}
8055                    ]
8056                }
8057            ]
8058        });
8059        let doc: AdfDocument = serde_json::from_value(adf_json).unwrap();
8060        let md = adf_to_markdown(&doc).unwrap();
8061        assert!(
8062            !md.contains("<!-- unsupported inline"),
8063            "mediaInline should not be unsupported; got: {md}"
8064        );
8065
8066        let rt = markdown_to_adf(&md).unwrap();
8067        let content = rt.content[0].content.as_ref().unwrap();
8068        assert_eq!(content[1].node_type, "mediaInline");
8069        let attrs = content[1].attrs.as_ref().unwrap();
8070        assert_eq!(attrs["type"], "image");
8071        assert_eq!(attrs["id"], "abcdef01-2345-6789-abcd-abcdef012345");
8072        assert_eq!(attrs["collection"], "contentId-111111");
8073        assert_eq!(attrs["width"], 200);
8074        assert_eq!(attrs["height"], 100);
8075        assert_eq!(attrs["localId"], "aabbccdd-1234-5678-abcd-aabbccdd1234");
8076    }
8077
8078    #[test]
8079    fn emoji_shortcode() {
8080        let doc = markdown_to_adf("Hello :wave: world").unwrap();
8081        let content = doc.content[0].content.as_ref().unwrap();
8082        assert_eq!(content[0].text.as_deref(), Some("Hello "));
8083        assert_eq!(content[1].node_type, "emoji");
8084        assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":wave:");
8085        assert_eq!(content[2].text.as_deref(), Some(" world"));
8086    }
8087
8088    #[test]
8089    fn adf_emoji_to_markdown() {
8090        let doc = AdfDocument {
8091            version: 1,
8092            doc_type: "doc".to_string(),
8093            content: vec![AdfNode::paragraph(vec![AdfNode::emoji("thumbsup")])],
8094        };
8095        let md = adf_to_markdown(&doc).unwrap();
8096        assert!(md.contains(":thumbsup:"));
8097    }
8098
8099    #[test]
8100    fn adf_emoji_with_colon_prefix_to_markdown() {
8101        // JIRA stores shortName as ":thumbsup:" with colons
8102        let doc = AdfDocument {
8103            version: 1,
8104            doc_type: "doc".to_string(),
8105            content: vec![AdfNode::paragraph(vec![AdfNode {
8106                node_type: "emoji".to_string(),
8107                attrs: Some(serde_json::json!({"shortName": ":thumbsup:"})),
8108                content: None,
8109                text: None,
8110                marks: None,
8111                local_id: None,
8112                parameters: None,
8113            }])],
8114        };
8115        let md = adf_to_markdown(&doc).unwrap();
8116        assert!(md.contains(":thumbsup:"));
8117        // Should not produce ::thumbsup:: (double colons)
8118        assert!(!md.contains("::thumbsup::"));
8119    }
8120
8121    #[test]
8122    fn round_trip_emoji() {
8123        let md = "Hello :wave: world\n";
8124        let doc = markdown_to_adf(md).unwrap();
8125        let result = adf_to_markdown(&doc).unwrap();
8126        assert!(result.contains(":wave:"));
8127    }
8128
8129    #[test]
8130    fn emoji_with_id_and_text_round_trips() {
8131        let doc = AdfDocument {
8132            version: 1,
8133            doc_type: "doc".to_string(),
8134            content: vec![AdfNode::paragraph(vec![AdfNode {
8135                node_type: "emoji".to_string(),
8136                attrs: Some(
8137                    serde_json::json!({"shortName": ":check_mark:", "id": "2705", "text": "✅"}),
8138                ),
8139                content: None,
8140                text: None,
8141                marks: None,
8142                local_id: None,
8143                parameters: None,
8144            }])],
8145        };
8146        let md = adf_to_markdown(&doc).unwrap();
8147        assert!(md.contains(":check_mark:"), "shortcode present: {md}");
8148        assert!(md.contains("id="), "id attr present: {md}");
8149        assert!(md.contains("text="), "text attr present: {md}");
8150
8151        // Round-trip back to ADF
8152        let round_tripped = markdown_to_adf(&md).unwrap();
8153        let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
8154        let attrs = emoji.attrs.as_ref().unwrap();
8155        assert_eq!(attrs["shortName"], ":check_mark:");
8156        assert_eq!(attrs["id"], "2705");
8157        assert_eq!(attrs["text"], "✅");
8158    }
8159
8160    #[test]
8161    fn emoji_without_extra_attrs_still_works() {
8162        let md = "Hello :wave: world\n";
8163        let doc = markdown_to_adf(md).unwrap();
8164        let emoji = &doc.content[0].content.as_ref().unwrap()[1];
8165        assert_eq!(emoji.attrs.as_ref().unwrap()["shortName"], ":wave:");
8166        // No id or text attrs when not provided
8167        assert!(emoji.attrs.as_ref().unwrap().get("id").is_none());
8168    }
8169
8170    #[test]
8171    fn emoji_shortname_preserves_colons_round_trip() {
8172        // Issue #362: emoji shortName colons stripped during round-trip
8173        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8174          {"type":"emoji","attrs":{"shortName":":cross_mark:","id":"atlassian-cross_mark","text":"❌"}}
8175        ]}]}"#;
8176        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8177
8178        // ADF → markdown → ADF round-trip
8179        let md = adf_to_markdown(&doc).unwrap();
8180        let round_tripped = markdown_to_adf(&md).unwrap();
8181        let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
8182        let attrs = emoji.attrs.as_ref().unwrap();
8183        assert_eq!(
8184            attrs["shortName"], ":cross_mark:",
8185            "shortName should preserve colons, got: {}",
8186            attrs["shortName"]
8187        );
8188        assert_eq!(attrs["id"], "atlassian-cross_mark");
8189        assert_eq!(attrs["text"], "❌");
8190    }
8191
8192    #[test]
8193    fn emoji_shortname_without_colons_preserved() {
8194        // Issue #379: shortName without colons should not gain colons
8195        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8196          {"type":"emoji","attrs":{"shortName":"white_check_mark","id":"2705","text":"✅"}}
8197        ]}]}"#;
8198        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8199        let md = adf_to_markdown(&doc).unwrap();
8200        let round_tripped = markdown_to_adf(&md).unwrap();
8201        let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
8202        let attrs = emoji.attrs.as_ref().unwrap();
8203        assert_eq!(
8204            attrs["shortName"], "white_check_mark",
8205            "shortName without colons should stay without colons, got: {}",
8206            attrs["shortName"]
8207        );
8208    }
8209
8210    #[test]
8211    fn colon_in_text_not_emoji() {
8212        // A lone colon should not trigger emoji parsing
8213        let doc = markdown_to_adf("Time is 10:30 today").unwrap();
8214        let content = doc.content[0].content.as_ref().unwrap();
8215        assert_eq!(content.len(), 1);
8216        assert_eq!(content[0].node_type, "text");
8217    }
8218
8219    #[test]
8220    fn text_with_shortcode_pattern_round_trips_as_text() {
8221        // Issue #450: `:fire:` in a text node must not become an emoji node
8222        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Alert :fire: triggered on pod:pod42"}]}]}"#;
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!(
8230            content.len(),
8231            1,
8232            "should be a single text node, got: {content:?}"
8233        );
8234        assert_eq!(content[0].node_type, "text");
8235        assert_eq!(
8236            content[0].text.as_deref().unwrap(),
8237            "Alert :fire: triggered on pod:pod42"
8238        );
8239    }
8240
8241    #[test]
8242    fn double_colon_pattern_round_trips_as_text() {
8243        // Issue #450: `::Active::` should not be parsed as emoji `:Active:`
8244        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Status::Active::Running"}]}]}"#;
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!(
8252            content.len(),
8253            1,
8254            "should be a single text node, got: {content:?}"
8255        );
8256        assert_eq!(content[0].node_type, "text");
8257        assert_eq!(
8258            content[0].text.as_deref().unwrap(),
8259            "Status::Active::Running"
8260        );
8261    }
8262
8263    #[test]
8264    fn real_emoji_node_still_round_trips() {
8265        // Ensure actual emoji ADF nodes still work after the escaping fix
8266        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8267          {"type":"text","text":"Hello "},
8268          {"type":"emoji","attrs":{"shortName":":fire:","id":"1f525","text":"🔥"}},
8269          {"type":"text","text":" world"}
8270        ]}]}"#;
8271        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8272
8273        let md = adf_to_markdown(&doc).unwrap();
8274        let round_tripped = markdown_to_adf(&md).unwrap();
8275        let content = round_tripped.content[0].content.as_ref().unwrap();
8276
8277        // Should have: text("Hello ") + emoji(:fire:) + text(" world")
8278        assert_eq!(content.len(), 3, "should have 3 nodes: {content:?}");
8279        assert_eq!(content[0].text.as_deref(), Some("Hello "));
8280        assert_eq!(content[1].node_type, "emoji");
8281        assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":fire:");
8282        assert_eq!(content[2].text.as_deref(), Some(" world"));
8283    }
8284
8285    #[test]
8286    fn combined_emoji_shortname_round_trips_as_single_node() {
8287        // Issue #576: an emoji node whose shortName is a combination of two
8288        // shortcodes (e.g. ":slightly_smiling_face::bow:") must survive the
8289        // ADF → markdown → ADF round-trip as a single emoji node rather than
8290        // being split into two.
8291        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8292          {"type":"text","text":"Thanks for the help "},
8293          {"type":"emoji","attrs":{"shortName":":slightly_smiling_face::bow:","id":"","text":""}}
8294        ]}]}"#;
8295        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8296
8297        let md = adf_to_markdown(&doc).unwrap();
8298        let round_tripped = markdown_to_adf(&md).unwrap();
8299        let content = round_tripped.content[0].content.as_ref().unwrap();
8300
8301        assert_eq!(
8302            content.len(),
8303            2,
8304            "should have text + single combined emoji: {content:?}"
8305        );
8306        assert_eq!(content[0].text.as_deref(), Some("Thanks for the help "));
8307        assert_eq!(content[1].node_type, "emoji");
8308        let attrs = content[1].attrs.as_ref().unwrap();
8309        assert_eq!(attrs["shortName"], ":slightly_smiling_face::bow:");
8310        assert_eq!(attrs["id"], "");
8311        assert_eq!(attrs["text"], "");
8312    }
8313
8314    #[test]
8315    fn triple_combined_emoji_shortname_round_trips_as_single_node() {
8316        // Three-part combined shortName must also survive round-trip.
8317        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8318          {"type":"emoji","attrs":{"shortName":":a::b::c:","id":"x","text":""}}
8319        ]}]}"#;
8320        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8321
8322        let md = adf_to_markdown(&doc).unwrap();
8323        let round_tripped = markdown_to_adf(&md).unwrap();
8324        let content = round_tripped.content[0].content.as_ref().unwrap();
8325
8326        assert_eq!(content.len(), 1, "should be single emoji: {content:?}");
8327        assert_eq!(content[0].node_type, "emoji");
8328        let attrs = content[0].attrs.as_ref().unwrap();
8329        assert_eq!(attrs["shortName"], ":a::b::c:");
8330        assert_eq!(attrs["id"], "x");
8331    }
8332
8333    #[test]
8334    fn consecutive_emojis_remain_separate_nodes() {
8335        // Two independent emoji nodes (each with their own directive) must
8336        // remain two separate nodes — the combined-chain logic must not
8337        // swallow the second emoji.
8338        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8339          {"type":"emoji","attrs":{"shortName":":fire:","id":"1f525","text":"🔥"}},
8340          {"type":"emoji","attrs":{"shortName":":water:","id":"1f4a7","text":"💧"}}
8341        ]}]}"#;
8342        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8343
8344        let md = adf_to_markdown(&doc).unwrap();
8345        let round_tripped = markdown_to_adf(&md).unwrap();
8346        let content = round_tripped.content[0].content.as_ref().unwrap();
8347
8348        assert_eq!(content.len(), 2, "should be two emoji nodes: {content:?}");
8349        assert_eq!(content[0].node_type, "emoji");
8350        assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":fire:");
8351        assert_eq!(content[1].node_type, "emoji");
8352        assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":water:");
8353    }
8354
8355    #[test]
8356    fn adjacent_shortcodes_without_directive_parse_as_two_emojis() {
8357        // Raw markdown with two adjacent shortcodes and no directive should
8358        // still parse as two separate emoji nodes.
8359        let md = ":fire::water:";
8360        let doc = markdown_to_adf(md).unwrap();
8361        let content = doc.content[0].content.as_ref().unwrap();
8362
8363        assert_eq!(content.len(), 2, "should be two emojis: {content:?}");
8364        assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":fire:");
8365        assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":water:");
8366    }
8367
8368    #[test]
8369    fn combined_emoji_shortname_preserves_local_id() {
8370        // The directive's localId should be preserved when the chain is
8371        // collapsed into a single emoji node.
8372        let md = r#":a::b:{shortName=":a::b:" id="x" text="y" localId="abc"}"#;
8373        let doc = markdown_to_adf(md).unwrap();
8374        let content = doc.content[0].content.as_ref().unwrap();
8375
8376        assert_eq!(content.len(), 1, "should be single emoji: {content:?}");
8377        let attrs = content[0].attrs.as_ref().unwrap();
8378        assert_eq!(attrs["shortName"], ":a::b:");
8379        assert_eq!(attrs["id"], "x");
8380        assert_eq!(attrs["text"], "y");
8381        assert_eq!(attrs["localId"], "abc");
8382    }
8383
8384    #[test]
8385    fn text_shortcode_with_marks_round_trips() {
8386        // Bold text containing an emoji-like shortcode should round-trip as bold text
8387        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8388          {"type":"text","text":"Alert :fire: triggered","marks":[{"type":"strong"}]}
8389        ]}]}"#;
8390        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8391
8392        let md = adf_to_markdown(&doc).unwrap();
8393        let round_tripped = markdown_to_adf(&md).unwrap();
8394        let content = round_tripped.content[0].content.as_ref().unwrap();
8395
8396        assert_eq!(
8397            content.len(),
8398            1,
8399            "should be single bold text node: {content:?}"
8400        );
8401        assert_eq!(content[0].node_type, "text");
8402        assert_eq!(
8403            content[0].text.as_deref().unwrap(),
8404            "Alert :fire: triggered"
8405        );
8406        assert!(content[0]
8407            .marks
8408            .as_ref()
8409            .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong")));
8410    }
8411
8412    #[test]
8413    fn mixed_emoji_node_and_text_shortcode_round_trips() {
8414        // Real emoji node adjacent to text containing a shortcode-like pattern
8415        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8416          {"type":"emoji","attrs":{"shortName":":wave:","id":"1f44b","text":"👋"}},
8417          {"type":"text","text":" says :hello: to you"}
8418        ]}]}"#;
8419        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8420
8421        let md = adf_to_markdown(&doc).unwrap();
8422        let round_tripped = markdown_to_adf(&md).unwrap();
8423        let content = round_tripped.content[0].content.as_ref().unwrap();
8424
8425        // Should be: emoji(:wave:) + text(" says :hello: to you")
8426        assert_eq!(content.len(), 2, "should have 2 nodes: {content:?}");
8427        assert_eq!(content[0].node_type, "emoji");
8428        assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":wave:");
8429        assert_eq!(content[1].node_type, "text");
8430        assert_eq!(content[1].text.as_deref().unwrap(), " says :hello: to you");
8431    }
8432
8433    #[test]
8434    fn code_block_with_shortcode_pattern_round_trips() {
8435        // Issue #552: Code block content containing `::Name::` patterns must not
8436        // be re-parsed as emoji shortcodes.
8437        let adf_json = r#"{"version":1,"type":"doc","content":[
8438          {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8439            {"type":"text","text":"module Foo::Bar::Baz\n  def hello\n    puts 'world'\n  end\nend"}
8440          ]}
8441        ]}"#;
8442        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8443
8444        let md = adf_to_markdown(&doc).unwrap();
8445        let round_tripped = markdown_to_adf(&md).unwrap();
8446
8447        assert_eq!(
8448            round_tripped.content.len(),
8449            1,
8450            "should be a single codeBlock"
8451        );
8452        let cb = &round_tripped.content[0];
8453        assert_eq!(cb.node_type, "codeBlock");
8454        let content = cb.content.as_ref().expect("codeBlock content");
8455        assert_eq!(
8456            content.len(),
8457            1,
8458            "should be a single text node: {content:?}"
8459        );
8460        assert_eq!(content[0].node_type, "text");
8461        assert_eq!(
8462            content[0].text.as_deref().unwrap(),
8463            "module Foo::Bar::Baz\n  def hello\n    puts 'world'\n  end\nend"
8464        );
8465        assert!(
8466            content.iter().all(|n| n.node_type != "emoji"),
8467            "no emoji nodes should be present, got: {content:?}"
8468        );
8469    }
8470
8471    #[test]
8472    fn code_block_with_exact_namespaced_shortcode_pattern_round_trips() {
8473        // Issue #552: Use a namespaced pattern modeled on the bug report.
8474        let adf_json = r#"{"version":1,"type":"doc","content":[
8475          {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8476            {"type":"text","text":"class ZBC::Acme::PlanType::Professional < Base"}
8477          ]}
8478        ]}"#;
8479        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8480
8481        let md = adf_to_markdown(&doc).unwrap();
8482        let round_tripped = markdown_to_adf(&md).unwrap();
8483
8484        let cb = &round_tripped.content[0];
8485        assert_eq!(cb.node_type, "codeBlock");
8486        let content = cb.content.as_ref().expect("codeBlock content");
8487        assert_eq!(content.len(), 1, "should be a single text node");
8488        assert_eq!(
8489            content[0].text.as_deref().unwrap(),
8490            "class ZBC::Acme::PlanType::Professional < Base"
8491        );
8492    }
8493
8494    #[test]
8495    fn code_block_with_literal_shortcode_round_trips() {
8496        // Issue #552: Content that is exactly a shortcode (`:fire:`) inside a
8497        // code block should survive the round-trip as literal text, not emoji.
8498        let adf_json = r#"{"version":1,"type":"doc","content":[
8499          {"type":"codeBlock","attrs":{"language":"text"},"content":[
8500            {"type":"text","text":":fire: :wave: :thumbsup:"}
8501          ]}
8502        ]}"#;
8503        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8504
8505        let md = adf_to_markdown(&doc).unwrap();
8506        let round_tripped = markdown_to_adf(&md).unwrap();
8507
8508        let cb = &round_tripped.content[0];
8509        assert_eq!(cb.node_type, "codeBlock");
8510        let content = cb.content.as_ref().expect("codeBlock content");
8511        assert_eq!(
8512            content.len(),
8513            1,
8514            "should be a single text node: {content:?}"
8515        );
8516        assert_eq!(content[0].node_type, "text");
8517        assert_eq!(
8518            content[0].text.as_deref().unwrap(),
8519            ":fire: :wave: :thumbsup:"
8520        );
8521    }
8522
8523    #[test]
8524    fn inline_code_with_shortcode_pattern_round_trips() {
8525        // Issue #552: Inline code containing `::Name::` patterns must not be
8526        // re-parsed as emoji shortcodes.
8527        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8528          {"type":"text","text":"See "},
8529          {"type":"text","text":"Foo::Bar::Baz","marks":[{"type":"code"}]},
8530          {"type":"text","text":" for details"}
8531        ]}]}"#;
8532        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8533
8534        let md = adf_to_markdown(&doc).unwrap();
8535        let round_tripped = markdown_to_adf(&md).unwrap();
8536        let content = round_tripped.content[0].content.as_ref().unwrap();
8537
8538        assert_eq!(content.len(), 3, "should have 3 text nodes: {content:?}");
8539        assert_eq!(content[0].text.as_deref(), Some("See "));
8540        assert_eq!(content[1].text.as_deref(), Some("Foo::Bar::Baz"));
8541        assert!(content[1]
8542            .marks
8543            .as_ref()
8544            .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
8545        assert_eq!(content[2].text.as_deref(), Some(" for details"));
8546        assert!(
8547            content.iter().all(|n| n.node_type != "emoji"),
8548            "no emoji nodes should be present"
8549        );
8550    }
8551
8552    #[test]
8553    fn inline_code_with_literal_shortcode_round_trips() {
8554        // Issue #552: Inline code whose content is exactly a shortcode must be
8555        // preserved as code, not converted to an emoji.
8556        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8557          {"type":"text","text":":fire:","marks":[{"type":"code"}]}
8558        ]}]}"#;
8559        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8560
8561        let md = adf_to_markdown(&doc).unwrap();
8562        let round_tripped = markdown_to_adf(&md).unwrap();
8563        let content = round_tripped.content[0].content.as_ref().unwrap();
8564
8565        assert_eq!(
8566            content.len(),
8567            1,
8568            "should be a single code node: {content:?}"
8569        );
8570        assert_eq!(content[0].node_type, "text");
8571        assert_eq!(content[0].text.as_deref(), Some(":fire:"));
8572        assert!(content[0]
8573            .marks
8574            .as_ref()
8575            .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
8576    }
8577
8578    #[test]
8579    fn code_block_in_list_with_shortcode_pattern_round_trips() {
8580        // Issue #552: A code block containing shortcode-like patterns nested in
8581        // a list should still survive round-trip without emoji corruption.
8582        let adf_json = r#"{"version":1,"type":"doc","content":[
8583          {"type":"bulletList","content":[
8584            {"type":"listItem","content":[
8585              {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8586                {"type":"text","text":"Foo::Bar::Baz"}
8587              ]}
8588            ]}
8589          ]}
8590        ]}"#;
8591        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8592
8593        let md = adf_to_markdown(&doc).unwrap();
8594        let round_tripped = markdown_to_adf(&md).unwrap();
8595
8596        let list = &round_tripped.content[0];
8597        assert_eq!(list.node_type, "bulletList");
8598        let item = &list.content.as_ref().unwrap()[0];
8599        assert_eq!(item.node_type, "listItem");
8600        let cb = &item.content.as_ref().unwrap()[0];
8601        assert_eq!(cb.node_type, "codeBlock");
8602        let cb_content = cb.content.as_ref().unwrap();
8603        assert_eq!(cb_content[0].text.as_deref(), Some("Foo::Bar::Baz"));
8604        assert_eq!(cb_content[0].node_type, "text");
8605    }
8606
8607    #[test]
8608    fn code_block_with_unicode_shortcode_pattern_round_trips() {
8609        // Issue #552: Code block content with non-ASCII colon-delimited words
8610        // (e.g. CJK or accented characters) must round-trip without splitting
8611        // or emoji corruption.
8612        let adf_json = r#"{"version":1,"type":"doc","content":[
8613          {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8614            {"type":"text","text":"class ZBC::配置::Production < Base"}
8615          ]}
8616        ]}"#;
8617        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8618
8619        let md = adf_to_markdown(&doc).unwrap();
8620        let round_tripped = markdown_to_adf(&md).unwrap();
8621
8622        let cb = &round_tripped.content[0];
8623        assert_eq!(cb.node_type, "codeBlock");
8624        let content = cb.content.as_ref().expect("codeBlock content");
8625        assert_eq!(content.len(), 1);
8626        assert_eq!(
8627            content[0].text.as_deref().unwrap(),
8628            "class ZBC::配置::Production < Base"
8629        );
8630    }
8631
8632    #[test]
8633    fn list_item_hardbreak_then_code_block_round_trips() {
8634        // Issue #552: A list item whose first paragraph ends with a hardBreak
8635        // followed by a codeBlock must round-trip correctly.  Previously, the
8636        // hardBreak's `\` continuation swallowed the 2-space-indented code
8637        // fence line, turning the whole block into a paragraph where `::Bar::`
8638        // was re-parsed as an emoji.
8639        let adf_json = r#"{"version":1,"type":"doc","content":[
8640          {"type":"bulletList","content":[
8641            {"type":"listItem","content":[
8642              {"type":"paragraph","content":[
8643                {"type":"text","text":"Consider removing this check."},
8644                {"type":"hardBreak"}
8645              ]},
8646              {"type":"codeBlock","content":[
8647                {"type":"text","text":"x = Foo::Bar::Baz.new"}
8648              ]}
8649            ]}
8650          ]}
8651        ]}"#;
8652        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8653
8654        let md = adf_to_markdown(&doc).unwrap();
8655        let round_tripped = markdown_to_adf(&md).unwrap();
8656
8657        let list = &round_tripped.content[0];
8658        assert_eq!(list.node_type, "bulletList");
8659        let item = &list.content.as_ref().unwrap()[0];
8660        assert_eq!(item.node_type, "listItem");
8661        let item_content = item.content.as_ref().unwrap();
8662        assert_eq!(
8663            item_content.len(),
8664            2,
8665            "listItem should have paragraph + codeBlock, got: {item_content:?}"
8666        );
8667        assert_eq!(item_content[0].node_type, "paragraph");
8668        assert_eq!(item_content[1].node_type, "codeBlock");
8669
8670        // The code block text must survive verbatim — no emoji splitting.
8671        let cb_content = item_content[1].content.as_ref().unwrap();
8672        assert_eq!(cb_content[0].node_type, "text");
8673        assert_eq!(
8674            cb_content[0].text.as_deref().unwrap(),
8675            "x = Foo::Bar::Baz.new"
8676        );
8677
8678        // And there should be no emoji node anywhere in the tree.
8679        assert!(
8680            item_content
8681                .iter()
8682                .flat_map(|n| n.content.iter().flat_map(|c| c.iter()))
8683                .all(|n| n.node_type != "emoji"),
8684            "no emoji nodes should be present, got: {item_content:?}"
8685        );
8686    }
8687
8688    #[test]
8689    fn list_item_hardbreak_then_nested_list_still_works() {
8690        // Ensure the hardBreak continuation fix doesn't break nested list
8691        // handling — an indented `- item` line after a hardBreak is still a
8692        // nested list, not a code fence.
8693        let md = "- first\\\n  continuation text\\\n  - nested item\n";
8694        let doc = markdown_to_adf(md).unwrap();
8695        let list = &doc.content[0];
8696        assert_eq!(list.node_type, "bulletList");
8697        let item = &list.content.as_ref().unwrap()[0];
8698        // First paragraph should contain the continuation text joined via hardBreaks
8699        let item_content = item.content.as_ref().unwrap();
8700        let para = &item_content[0];
8701        assert_eq!(para.node_type, "paragraph");
8702        let para_nodes = para.content.as_ref().unwrap();
8703        assert!(
8704            para_nodes
8705                .iter()
8706                .any(|n| n.text.as_deref() == Some("continuation text")),
8707            "continuation text should survive: {para_nodes:?}"
8708        );
8709    }
8710
8711    #[test]
8712    fn list_item_hardbreak_then_image_still_works() {
8713        // Regression check for issue #490: the image-skip behaviour in
8714        // collect_hardbreak_continuations must still hold after the code-fence
8715        // fix.  The `![](url)` line must remain a block-level mediaSingle, not
8716        // be swallowed into the paragraph.
8717        let md = "- first\\\n  ![](https://example.com/x.png){type=file id=x}\n";
8718        let doc = markdown_to_adf(md).unwrap();
8719        let list = &doc.content[0];
8720        let item = &list.content.as_ref().unwrap()[0];
8721        let item_content = item.content.as_ref().unwrap();
8722        // The image should be a sibling block, not part of the first paragraph.
8723        assert!(
8724            item_content.iter().any(|n| n.node_type == "mediaSingle"),
8725            "mediaSingle should still be a block-level sibling, got: {item_content:?}"
8726        );
8727    }
8728
8729    #[test]
8730    fn list_item_hardbreak_then_container_directive_round_trips() {
8731        // Issue #552: Same hardBreak-swallows-block-siblings bug class — a
8732        // container directive (`:::panel`) after a hardBreak must also not be
8733        // consumed as a continuation line.
8734        let adf_json = r#"{"version":1,"type":"doc","content":[
8735          {"type":"bulletList","content":[
8736            {"type":"listItem","content":[
8737              {"type":"paragraph","content":[
8738                {"type":"text","text":"intro"},
8739                {"type":"hardBreak"}
8740              ]},
8741              {"type":"panel","attrs":{"panelType":"info"},"content":[
8742                {"type":"paragraph","content":[
8743                  {"type":"text","text":"panel body"}
8744                ]}
8745              ]}
8746            ]}
8747          ]}
8748        ]}"#;
8749        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8750        let md = adf_to_markdown(&doc).unwrap();
8751        let round_tripped = markdown_to_adf(&md).unwrap();
8752
8753        let item = &round_tripped.content[0].content.as_ref().unwrap()[0];
8754        let item_content = item.content.as_ref().unwrap();
8755        assert!(
8756            item_content.iter().any(|n| n.node_type == "panel"),
8757            "panel should survive as block-level sibling, got: {item_content:?}"
8758        );
8759    }
8760
8761    #[test]
8762    fn inline_code_with_unicode_shortcode_pattern_round_trips() {
8763        // Issue #552: Inline code containing non-ASCII colon-delimited words
8764        // must round-trip without emoji corruption.
8765        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8766          {"type":"text","text":"See "},
8767          {"type":"text","text":"ZBC::配置::Production","marks":[{"type":"code"}]},
8768          {"type":"text","text":" for prod"}
8769        ]}]}"#;
8770        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8771
8772        let md = adf_to_markdown(&doc).unwrap();
8773        let round_tripped = markdown_to_adf(&md).unwrap();
8774        let content = round_tripped.content[0].content.as_ref().unwrap();
8775
8776        assert_eq!(content.len(), 3, "should have 3 nodes: {content:?}");
8777        assert_eq!(content[1].text.as_deref(), Some("ZBC::配置::Production"));
8778        assert!(content[1]
8779            .marks
8780            .as_ref()
8781            .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
8782    }
8783
8784    #[test]
8785    fn code_block_followed_by_shortcode_text_round_trips() {
8786        // Issue #552: A code block with colon-delimited content followed by a
8787        // paragraph containing emoji-like text should not confuse parsing.
8788        let adf_json = r#"{"version":1,"type":"doc","content":[
8789          {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8790            {"type":"text","text":"Foo::Bar::Baz"}
8791          ]},
8792          {"type":"paragraph","content":[
8793            {"type":"text","text":"Status::Active::Running"}
8794          ]}
8795        ]}"#;
8796        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8797
8798        let md = adf_to_markdown(&doc).unwrap();
8799        let round_tripped = markdown_to_adf(&md).unwrap();
8800
8801        assert_eq!(round_tripped.content.len(), 2);
8802        let cb = &round_tripped.content[0];
8803        assert_eq!(cb.node_type, "codeBlock");
8804        let cb_content = cb.content.as_ref().unwrap();
8805        assert_eq!(cb_content[0].text.as_deref(), Some("Foo::Bar::Baz"));
8806
8807        let para = &round_tripped.content[1];
8808        assert_eq!(para.node_type, "paragraph");
8809        let para_content = para.content.as_ref().unwrap();
8810        assert_eq!(para_content.len(), 1);
8811        assert_eq!(para_content[0].node_type, "text");
8812        assert_eq!(
8813            para_content[0].text.as_deref(),
8814            Some("Status::Active::Running")
8815        );
8816    }
8817
8818    #[test]
8819    fn adf_inline_card_to_markdown() {
8820        let doc = AdfDocument {
8821            version: 1,
8822            doc_type: "doc".to_string(),
8823            content: vec![AdfNode::paragraph(vec![AdfNode {
8824                node_type: "inlineCard".to_string(),
8825                attrs: Some(
8826                    serde_json::json!({"url": "https://org.atlassian.net/browse/ACCS-4382"}),
8827                ),
8828                content: None,
8829                text: None,
8830                marks: None,
8831                local_id: None,
8832                parameters: None,
8833            }])],
8834        };
8835        let md = adf_to_markdown(&doc).unwrap();
8836        assert!(md.contains(":card[https://org.atlassian.net/browse/ACCS-4382]"));
8837        assert!(!md.contains("<!-- unsupported inline"));
8838    }
8839
8840    #[test]
8841    fn inline_card_directive_round_trips() {
8842        // inlineCard → :card[url] → inlineCard
8843        let original = AdfDocument {
8844            version: 1,
8845            doc_type: "doc".to_string(),
8846            content: vec![AdfNode::paragraph(vec![AdfNode::inline_card(
8847                "https://org.atlassian.net/browse/ACCS-4382",
8848            )])],
8849        };
8850        let md = adf_to_markdown(&original).unwrap();
8851        assert!(md.contains(":card[https://org.atlassian.net/browse/ACCS-4382]"));
8852        let restored = markdown_to_adf(&md).unwrap();
8853        let node = &restored.content[0].content.as_ref().unwrap()[0];
8854        assert_eq!(node.node_type, "inlineCard");
8855        assert_eq!(
8856            node.attrs.as_ref().unwrap()["url"],
8857            "https://org.atlassian.net/browse/ACCS-4382"
8858        );
8859    }
8860
8861    #[test]
8862    fn inline_card_directive_parsed_from_jfm() {
8863        // :card[url] in JFM → inlineCard in ADF
8864        let doc = markdown_to_adf("See :card[https://example.com/issue/123] for details.").unwrap();
8865        let nodes = doc.content[0].content.as_ref().unwrap();
8866        assert_eq!(nodes[0].node_type, "text");
8867        assert_eq!(nodes[0].text.as_deref(), Some("See "));
8868        assert_eq!(nodes[1].node_type, "inlineCard");
8869        assert_eq!(
8870            nodes[1].attrs.as_ref().unwrap()["url"],
8871            "https://example.com/issue/123"
8872        );
8873        assert_eq!(nodes[2].node_type, "text");
8874        assert_eq!(nodes[2].text.as_deref(), Some(" for details."));
8875    }
8876
8877    #[test]
8878    fn self_link_becomes_link_mark_not_inline_card() {
8879        // Issue #378: [url](url) should produce a link mark, not inlineCard.
8880        // inlineCard is only for :card[url] directives and bare URLs.
8881        let doc = markdown_to_adf("[https://example.com](https://example.com)").unwrap();
8882        let node = &doc.content[0].content.as_ref().unwrap()[0];
8883        assert_eq!(node.node_type, "text");
8884        assert_eq!(node.text.as_deref(), Some("https://example.com"));
8885        let mark = &node.marks.as_ref().unwrap()[0];
8886        assert_eq!(mark.mark_type, "link");
8887        assert_eq!(mark.attrs.as_ref().unwrap()["href"], "https://example.com");
8888    }
8889
8890    #[test]
8891    fn url_link_text_with_trailing_slash_mismatch_becomes_link_mark() {
8892        // Issue #523: [url](url/) where text and href differ only by trailing
8893        // slash should produce a text node with link mark, not an inlineCard.
8894        let doc =
8895            markdown_to_adf("[https://octopz.example.com](https://octopz.example.com/)").unwrap();
8896        let node = &doc.content[0].content.as_ref().unwrap()[0];
8897        assert_eq!(node.node_type, "text");
8898        assert_eq!(node.text.as_deref(), Some("https://octopz.example.com"));
8899        let mark = &node.marks.as_ref().unwrap()[0];
8900        assert_eq!(mark.mark_type, "link");
8901        assert_eq!(
8902            mark.attrs.as_ref().unwrap()["href"],
8903            "https://octopz.example.com/"
8904        );
8905    }
8906
8907    #[test]
8908    fn named_link_does_not_become_inline_card() {
8909        // [#4668](url) — text differs from url, stays as a link mark
8910        let doc = markdown_to_adf("[#4668](https://github.com/org/repo/pull/4668)").unwrap();
8911        let node = &doc.content[0].content.as_ref().unwrap()[0];
8912        assert_eq!(node.node_type, "text");
8913        assert_eq!(node.text.as_deref(), Some("#4668"));
8914        let mark = &node.marks.as_ref().unwrap()[0];
8915        assert_eq!(mark.mark_type, "link");
8916    }
8917
8918    #[test]
8919    fn adf_inline_card_no_url_to_markdown() {
8920        let doc = AdfDocument {
8921            version: 1,
8922            doc_type: "doc".to_string(),
8923            content: vec![AdfNode::paragraph(vec![AdfNode {
8924                node_type: "inlineCard".to_string(),
8925                attrs: Some(serde_json::json!({})),
8926                content: None,
8927                text: None,
8928                marks: None,
8929                local_id: None,
8930                parameters: None,
8931            }])],
8932        };
8933        let md = adf_to_markdown(&doc).unwrap();
8934        // No url attr — renders nothing (not a comment)
8935        assert!(!md.contains("<!-- unsupported inline"));
8936    }
8937
8938    #[test]
8939    fn adf_code_block_no_language_to_markdown() {
8940        let doc = AdfDocument {
8941            version: 1,
8942            doc_type: "doc".to_string(),
8943            content: vec![AdfNode::code_block(None, "plain code")],
8944        };
8945        let md = adf_to_markdown(&doc).unwrap();
8946        assert!(md.contains("```\n"));
8947        assert!(md.contains("plain code"));
8948    }
8949
8950    #[test]
8951    fn adf_code_block_empty_language_to_markdown() {
8952        let doc = AdfDocument {
8953            version: 1,
8954            doc_type: "doc".to_string(),
8955            content: vec![AdfNode::code_block(Some(""), "plain code")],
8956        };
8957        let md = adf_to_markdown(&doc).unwrap();
8958        assert!(md.contains("```\"\"\n"));
8959        assert!(md.contains("plain code"));
8960    }
8961
8962    // ── Additional round-trip tests ────────────────────────────────────
8963
8964    #[test]
8965    fn round_trip_table() {
8966        let md = "| A | B |\n| --- | --- |\n| 1 | 2 |\n";
8967        let adf = markdown_to_adf(md).unwrap();
8968        let restored = adf_to_markdown(&adf).unwrap();
8969        assert!(restored.contains("| A | B |"));
8970        assert!(restored.contains("| 1 | 2 |"));
8971    }
8972
8973    #[test]
8974    fn round_trip_blockquote() {
8975        let md = "> This is quoted\n";
8976        let adf = markdown_to_adf(md).unwrap();
8977        let restored = adf_to_markdown(&adf).unwrap();
8978        assert!(restored.contains("> This is quoted"));
8979    }
8980
8981    #[test]
8982    fn round_trip_image() {
8983        let md = "![Logo](https://example.com/logo.png)\n";
8984        let adf = markdown_to_adf(md).unwrap();
8985        let restored = adf_to_markdown(&adf).unwrap();
8986        assert!(restored.contains("![Logo](https://example.com/logo.png)"));
8987    }
8988
8989    #[test]
8990    fn round_trip_ordered_list() {
8991        let md = "1. A\n2. B\n3. C\n";
8992        let adf = markdown_to_adf(md).unwrap();
8993        let restored = adf_to_markdown(&adf).unwrap();
8994        assert!(restored.contains("1. A"));
8995        assert!(restored.contains("2. B"));
8996        assert!(restored.contains("3. C"));
8997    }
8998
8999    #[test]
9000    fn round_trip_inline_marks() {
9001        let md = "Text with `code` and ~~strike~~ and [link](https://x.com).\n";
9002        let adf = markdown_to_adf(md).unwrap();
9003        let restored = adf_to_markdown(&adf).unwrap();
9004        assert!(restored.contains("`code`"));
9005        assert!(restored.contains("~~strike~~"));
9006        assert!(restored.contains("[link](https://x.com)"));
9007    }
9008
9009    // ── Container directive tests (Tier 2) ───────────────────────────
9010
9011    #[test]
9012    fn panel_info() {
9013        let md = ":::panel{type=info}\nThis is informational.\n:::";
9014        let doc = markdown_to_adf(md).unwrap();
9015        assert_eq!(doc.content[0].node_type, "panel");
9016        assert_eq!(doc.content[0].attrs.as_ref().unwrap()["panelType"], "info");
9017        let inner = doc.content[0].content.as_ref().unwrap();
9018        assert_eq!(inner[0].node_type, "paragraph");
9019    }
9020
9021    #[test]
9022    fn adf_panel_to_markdown() {
9023        let doc = AdfDocument {
9024            version: 1,
9025            doc_type: "doc".to_string(),
9026            content: vec![AdfNode::panel(
9027                "warning",
9028                vec![AdfNode::paragraph(vec![AdfNode::text("Be careful.")])],
9029            )],
9030        };
9031        let md = adf_to_markdown(&doc).unwrap();
9032        assert!(md.contains(":::panel{type=warning}"));
9033        assert!(md.contains("Be careful."));
9034        assert!(md.contains(":::"));
9035    }
9036
9037    #[test]
9038    fn round_trip_panel() {
9039        let md = ":::panel{type=info}\nThis is informational.\n:::\n";
9040        let doc = markdown_to_adf(md).unwrap();
9041        let result = adf_to_markdown(&doc).unwrap();
9042        assert!(result.contains(":::panel{type=info}"));
9043        assert!(result.contains("This is informational."));
9044    }
9045
9046    #[test]
9047    fn expand_with_title() {
9048        let md = ":::expand{title=\"Click me\"}\nHidden content.\n:::";
9049        let doc = markdown_to_adf(md).unwrap();
9050        assert_eq!(doc.content[0].node_type, "expand");
9051        assert_eq!(doc.content[0].attrs.as_ref().unwrap()["title"], "Click me");
9052    }
9053
9054    #[test]
9055    fn adf_expand_to_markdown() {
9056        let doc = AdfDocument {
9057            version: 1,
9058            doc_type: "doc".to_string(),
9059            content: vec![AdfNode::expand(
9060                Some("Details"),
9061                vec![AdfNode::paragraph(vec![AdfNode::text("Inner.")])],
9062            )],
9063        };
9064        let md = adf_to_markdown(&doc).unwrap();
9065        assert!(md.contains(":::expand{title=\"Details\"}"));
9066        assert!(md.contains("Inner."));
9067    }
9068
9069    #[test]
9070    fn round_trip_expand() {
9071        let md = ":::expand{title=\"Details\"}\nInner content.\n:::\n";
9072        let doc = markdown_to_adf(md).unwrap();
9073        let result = adf_to_markdown(&doc).unwrap();
9074        assert!(result.contains(":::expand{title=\"Details\"}"));
9075        assert!(result.contains("Inner content."));
9076    }
9077
9078    #[test]
9079    fn layout_two_columns() {
9080        let md =
9081            "::::layout\n:::column{width=50}\nLeft.\n:::\n:::column{width=50}\nRight.\n:::\n::::";
9082        let doc = markdown_to_adf(md).unwrap();
9083        assert_eq!(doc.content[0].node_type, "layoutSection");
9084        let columns = doc.content[0].content.as_ref().unwrap();
9085        assert_eq!(columns.len(), 2);
9086        assert_eq!(columns[0].node_type, "layoutColumn");
9087        assert_eq!(columns[1].node_type, "layoutColumn");
9088    }
9089
9090    #[test]
9091    fn adf_layout_to_markdown() {
9092        let doc = AdfDocument {
9093            version: 1,
9094            doc_type: "doc".to_string(),
9095            content: vec![AdfNode::layout_section(vec![
9096                AdfNode::layout_column(50, vec![AdfNode::paragraph(vec![AdfNode::text("Left.")])]),
9097                AdfNode::layout_column(50, vec![AdfNode::paragraph(vec![AdfNode::text("Right.")])]),
9098            ])],
9099        };
9100        let md = adf_to_markdown(&doc).unwrap();
9101        assert!(md.contains("::::layout"));
9102        assert!(md.contains(":::column{width=50}"));
9103        assert!(md.contains("Left."));
9104        assert!(md.contains("Right."));
9105    }
9106
9107    #[test]
9108    fn layout_column_localid_roundtrip() {
9109        let adf_json = r#"{
9110            "version": 1,
9111            "type": "doc",
9112            "content": [{
9113                "type": "layoutSection",
9114                "content": [
9115                    {
9116                        "type": "layoutColumn",
9117                        "attrs": {"width": 50.0, "localId": "aabb112233cc"},
9118                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Left"}]}]
9119                    },
9120                    {
9121                        "type": "layoutColumn",
9122                        "attrs": {"width": 50.0, "localId": "ddeeff445566"},
9123                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Right"}]}]
9124                    }
9125                ]
9126            }]
9127        }"#;
9128        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9129        let md = adf_to_markdown(&doc).unwrap();
9130        assert!(
9131            md.contains("localId=aabb112233cc"),
9132            "first column localId should appear in markdown: {md}"
9133        );
9134        assert!(
9135            md.contains("localId=ddeeff445566"),
9136            "second column localId should appear in markdown: {md}"
9137        );
9138        let rt = markdown_to_adf(&md).unwrap();
9139        let cols = rt.content[0].content.as_ref().unwrap();
9140        assert_eq!(
9141            cols[0].attrs.as_ref().unwrap()["localId"],
9142            "aabb112233cc",
9143            "first column localId should round-trip"
9144        );
9145        assert_eq!(
9146            cols[1].attrs.as_ref().unwrap()["localId"],
9147            "ddeeff445566",
9148            "second column localId should round-trip"
9149        );
9150    }
9151
9152    #[test]
9153    fn layout_column_without_localid() {
9154        let md =
9155            "::::layout\n:::column{width=50}\nLeft.\n:::\n:::column{width=50}\nRight.\n:::\n::::";
9156        let doc = markdown_to_adf(md).unwrap();
9157        let cols = doc.content[0].content.as_ref().unwrap();
9158        assert!(
9159            cols[0].attrs.as_ref().unwrap().get("localId").is_none(),
9160            "column without localId should not gain one"
9161        );
9162        let md2 = adf_to_markdown(&doc).unwrap();
9163        assert!(
9164            !md2.contains("localId"),
9165            "no localId should appear in output: {md2}"
9166        );
9167    }
9168
9169    #[test]
9170    fn layout_column_localid_stripped_when_option_set() {
9171        let adf_json = r#"{
9172            "version": 1,
9173            "type": "doc",
9174            "content": [{
9175                "type": "layoutSection",
9176                "content": [{
9177                    "type": "layoutColumn",
9178                    "attrs": {"width": 50.0, "localId": "aabb112233cc"},
9179                    "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Col"}]}]
9180                }]
9181            }]
9182        }"#;
9183        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9184        let opts = RenderOptions {
9185            strip_local_ids: true,
9186            ..Default::default()
9187        };
9188        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
9189        assert!(!md.contains("localId"), "localId should be stripped: {md}");
9190    }
9191
9192    #[test]
9193    fn layout_column_localid_flush_previous() {
9194        // Columns open without explicit `:::` close → flush-previous path
9195        let md = "::::layout\n:::column{width=50 localId=aabb112233cc}\nLeft.\n:::column{width=50 localId=ddeeff445566}\nRight.\n:::\n::::";
9196        let doc = markdown_to_adf(md).unwrap();
9197        let cols = doc.content[0].content.as_ref().unwrap();
9198        assert_eq!(
9199            cols[0].attrs.as_ref().unwrap()["localId"],
9200            "aabb112233cc",
9201            "flush-previous column should preserve localId"
9202        );
9203        assert_eq!(
9204            cols[1].attrs.as_ref().unwrap()["localId"],
9205            "ddeeff445566",
9206            "second column localId should be preserved"
9207        );
9208    }
9209
9210    #[test]
9211    fn layout_column_localid_flush_last() {
9212        // Layout with no closing fence → column never explicitly closed → flush-last path
9213        let md = "::::layout\n:::column{width=50 localId=aabb112233cc}\nOnly column.";
9214        let doc = markdown_to_adf(md).unwrap();
9215        let cols = doc.content[0].content.as_ref().unwrap();
9216        assert_eq!(
9217            cols[0].attrs.as_ref().unwrap()["localId"],
9218            "aabb112233cc",
9219            "flush-last column should preserve localId"
9220        );
9221    }
9222
9223    /// Issue #555: `layoutColumn` fractional `width` must round-trip byte-for-byte.
9224    #[test]
9225    fn issue_555_layout_column_fractional_width_roundtrip() {
9226        let adf_json = r#"{
9227            "version": 1,
9228            "type": "doc",
9229            "content": [{
9230                "type": "layoutSection",
9231                "content": [
9232                    {
9233                        "type": "layoutColumn",
9234                        "attrs": {"width": 66.66},
9235                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Wide"}]}]
9236                    },
9237                    {
9238                        "type": "layoutColumn",
9239                        "attrs": {"width": 33.34},
9240                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Narrow"}]}]
9241                    }
9242                ]
9243            }]
9244        }"#;
9245        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9246        let md = adf_to_markdown(&doc).unwrap();
9247        assert!(md.contains("width=66.66"), "fractional width in md: {md}");
9248        assert!(md.contains("width=33.34"), "fractional width in md: {md}");
9249        let rt = markdown_to_adf(&md).unwrap();
9250        let cols = rt.content[0].content.as_ref().unwrap();
9251        assert_eq!(cols[0].attrs.as_ref().unwrap()["width"], 66.66);
9252        assert_eq!(cols[1].attrs.as_ref().unwrap()["width"], 33.34);
9253    }
9254
9255    /// Issue #555: `layoutColumn` 5/6 widths (`83.33`) round-trip without precision loss.
9256    #[test]
9257    fn issue_555_layout_column_five_sixths_width_roundtrip() {
9258        let adf_json = r#"{
9259            "version": 1,
9260            "type": "doc",
9261            "content": [{
9262                "type": "layoutSection",
9263                "content": [
9264                    {
9265                        "type": "layoutColumn",
9266                        "attrs": {"width": 83.33},
9267                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Wide"}]}]
9268                    },
9269                    {
9270                        "type": "layoutColumn",
9271                        "attrs": {"width": 16.67},
9272                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Narrow"}]}]
9273                    }
9274                ]
9275            }]
9276        }"#;
9277        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9278        let md = adf_to_markdown(&doc).unwrap();
9279        let rt = markdown_to_adf(&md).unwrap();
9280        let cols = rt.content[0].content.as_ref().unwrap();
9281        assert_eq!(cols[0].attrs.as_ref().unwrap()["width"], 83.33);
9282        assert_eq!(cols[1].attrs.as_ref().unwrap()["width"], 16.67);
9283    }
9284
9285    /// Issue #555: `layoutColumn` integer widths must NOT be coerced to floats on round-trip.
9286    #[test]
9287    fn issue_555_layout_column_integer_width_preserved() {
9288        let adf_json = r#"{
9289            "version": 1,
9290            "type": "doc",
9291            "content": [{
9292                "type": "layoutSection",
9293                "content": [
9294                    {
9295                        "type": "layoutColumn",
9296                        "attrs": {"width": 50},
9297                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "A"}]}]
9298                    },
9299                    {
9300                        "type": "layoutColumn",
9301                        "attrs": {"width": 50},
9302                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "B"}]}]
9303                    }
9304                ]
9305            }]
9306        }"#;
9307        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9308        let md = adf_to_markdown(&doc).unwrap();
9309        assert!(
9310            md.contains("width=50") && !md.contains("width=50."),
9311            "integer width should render without decimal: {md}"
9312        );
9313        let rt = markdown_to_adf(&md).unwrap();
9314        let cols = rt.content[0].content.as_ref().unwrap();
9315        let w0 = &cols[0].attrs.as_ref().unwrap()["width"];
9316        assert!(
9317            w0.is_i64() || w0.is_u64(),
9318            "width should remain a JSON integer, got: {w0}"
9319        );
9320        assert_eq!(w0.as_i64(), Some(50));
9321    }
9322
9323    /// Issue #555: `mediaSingle` integer `width` must NOT be coerced to a float on round-trip.
9324    #[test]
9325    fn issue_555_media_single_integer_width_preserved() {
9326        let adf_json = r#"{
9327            "version": 1,
9328            "type": "doc",
9329            "content": [{
9330                "type": "mediaSingle",
9331                "attrs": {"layout": "center", "width": 800},
9332                "content": [
9333                    {"type": "media", "attrs": {"type": "external", "url": "https://example.com/image.png"}}
9334                ]
9335            }]
9336        }"#;
9337        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9338        let md = adf_to_markdown(&doc).unwrap();
9339        assert!(
9340            md.contains("width=800") && !md.contains("width=800."),
9341            "integer width should render without decimal: {md}"
9342        );
9343        let rt = markdown_to_adf(&md).unwrap();
9344        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9345        let w = &ms_attrs["width"];
9346        assert!(
9347            w.is_i64() || w.is_u64(),
9348            "mediaSingle width should remain JSON integer, got: {w}"
9349        );
9350        assert_eq!(w.as_i64(), Some(800));
9351    }
9352
9353    /// Issue #555 (follow-up): fractional `mediaSingle` width (e.g. `66.5`, a
9354    /// percentage-based size common in Jira layouts) must survive `from-adf`
9355    /// instead of being silently dropped.
9356    #[test]
9357    fn issue_555_media_single_fractional_width_preserved() {
9358        let adf_json = r#"{
9359            "version": 1,
9360            "type": "doc",
9361            "content": [{
9362                "type": "mediaSingle",
9363                "attrs": {"layout": "center", "width": 66.5},
9364                "content": [
9365                    {"type": "media", "attrs": {"type": "external", "url": "https://example.com/diagram.png"}}
9366                ]
9367            }]
9368        }"#;
9369        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9370        let md = adf_to_markdown(&doc).unwrap();
9371        assert!(
9372            md.contains("width=66.5"),
9373            "fractional width must appear in JFM: {md}"
9374        );
9375        let rt = markdown_to_adf(&md).unwrap();
9376        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9377        assert_eq!(ms_attrs["width"], 66.5);
9378    }
9379
9380    /// Issue #555: `mediaSingle` float `width` must not be dropped during ADF→JFM→ADF.
9381    #[test]
9382    fn issue_555_media_single_float_width_preserved() {
9383        let adf_json = r#"{
9384            "version": 1,
9385            "type": "doc",
9386            "content": [{
9387                "type": "mediaSingle",
9388                "attrs": {"layout": "center", "width": 800.0},
9389                "content": [
9390                    {"type": "media", "attrs": {"type": "external", "url": "https://example.com/image.png"}}
9391                ]
9392            }]
9393        }"#;
9394        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9395        let md = adf_to_markdown(&doc).unwrap();
9396        assert!(
9397            md.contains("width=800.0"),
9398            "float width should render with decimal: {md}"
9399        );
9400        let rt = markdown_to_adf(&md).unwrap();
9401        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9402        let w = &ms_attrs["width"];
9403        assert!(
9404            w.is_f64(),
9405            "mediaSingle float width should stay a JSON float, got: {w}"
9406        );
9407        assert_eq!(w.as_f64(), Some(800.0));
9408    }
9409
9410    /// Issue #555: `mediaSingle` with `layout=wide` and integer width must round-trip.
9411    #[test]
9412    fn issue_555_media_single_wide_layout_integer_width_roundtrip() {
9413        let adf_json = r#"{
9414            "version": 1,
9415            "type": "doc",
9416            "content": [{
9417                "type": "mediaSingle",
9418                "attrs": {"layout": "wide", "width": 1420},
9419                "content": [
9420                    {"type": "media", "attrs": {"type": "external", "url": "https://ex.com/x.png"}}
9421                ]
9422            }]
9423        }"#;
9424        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9425        let md = adf_to_markdown(&doc).unwrap();
9426        let rt = markdown_to_adf(&md).unwrap();
9427        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9428        assert_eq!(ms_attrs["layout"], "wide");
9429        let w = &ms_attrs["width"];
9430        assert!(
9431            w.is_i64() || w.is_u64(),
9432            "mediaSingle width should remain JSON integer, got: {w}"
9433        );
9434        assert_eq!(w.as_i64(), Some(1420));
9435    }
9436
9437    /// Issue #555: Confluence file-attachment `mediaSingle` with integer `mediaWidth`
9438    /// must round-trip without float coercion.
9439    #[test]
9440    fn issue_555_file_media_single_integer_width_preserved() {
9441        let adf_json = r#"{
9442            "version": 1,
9443            "type": "doc",
9444            "content": [{
9445                "type": "mediaSingle",
9446                "attrs": {"layout": "wide", "width": 1420},
9447                "content": [
9448                    {"type": "media", "attrs": {"id": "abc-123", "type": "file", "collection": "col-1", "width": 1200, "height": 800}}
9449                ]
9450            }]
9451        }"#;
9452        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9453        let md = adf_to_markdown(&doc).unwrap();
9454        let rt = markdown_to_adf(&md).unwrap();
9455        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9456        let ms_w = &ms_attrs["width"];
9457        assert!(ms_w.is_i64() || ms_w.is_u64(), "ms width: {ms_w}");
9458        assert_eq!(ms_w.as_i64(), Some(1420));
9459        let media = &rt.content[0].content.as_ref().unwrap()[0];
9460        let media_attrs = media.attrs.as_ref().unwrap();
9461        let mw = &media_attrs["width"];
9462        assert!(mw.is_i64() || mw.is_u64(), "media width: {mw}");
9463        assert_eq!(mw.as_i64(), Some(1200));
9464        let mh = &media_attrs["height"];
9465        assert!(mh.is_i64() || mh.is_u64(), "media height: {mh}");
9466        assert_eq!(mh.as_i64(), Some(800));
9467    }
9468
9469    /// Issue #555: `fmt_numeric_attr` preserves the original integer/float JSON type.
9470    #[test]
9471    fn issue_555_fmt_numeric_attr_preserves_type() {
9472        assert_eq!(
9473            fmt_numeric_attr(&serde_json::json!(50)).as_deref(),
9474            Some("50")
9475        );
9476        assert_eq!(
9477            fmt_numeric_attr(&serde_json::json!(50.0)).as_deref(),
9478            Some("50.0")
9479        );
9480        assert_eq!(
9481            fmt_numeric_attr(&serde_json::json!(66.66)).as_deref(),
9482            Some("66.66")
9483        );
9484        assert_eq!(
9485            fmt_numeric_attr(&serde_json::json!(-5)).as_deref(),
9486            Some("-5")
9487        );
9488        assert_eq!(fmt_numeric_attr(&serde_json::json!("not a number")), None);
9489        // u64 values above i64::MAX exercise the u64-only branch.
9490        let big = serde_json::Value::Number(serde_json::Number::from(u64::MAX));
9491        assert_eq!(
9492            fmt_numeric_attr(&big).as_deref(),
9493            Some("18446744073709551615")
9494        );
9495        // Null is not a number.
9496        assert_eq!(fmt_numeric_attr(&serde_json::Value::Null), None);
9497    }
9498
9499    /// Issue #555: `parse_numeric_attr` distinguishes integer vs float strings.
9500    #[test]
9501    fn issue_555_parse_numeric_attr_detects_type() {
9502        let v = parse_numeric_attr("800").unwrap();
9503        assert!(v.is_i64() || v.is_u64(), "'800' should parse as integer");
9504        assert_eq!(v.as_i64(), Some(800));
9505
9506        let v = parse_numeric_attr("800.0").unwrap();
9507        assert!(v.is_f64(), "'800.0' should parse as float");
9508        assert_eq!(v.as_f64(), Some(800.0));
9509
9510        let v = parse_numeric_attr("66.66").unwrap();
9511        assert!(v.is_f64());
9512        assert_eq!(v.as_f64(), Some(66.66));
9513
9514        // Scientific notation is treated as float (matches JSON semantics).
9515        let v = parse_numeric_attr("1e2").unwrap();
9516        assert!(v.is_f64());
9517        assert_eq!(v.as_f64(), Some(100.0));
9518        let v = parse_numeric_attr("1E2").unwrap();
9519        assert!(v.is_f64());
9520        assert_eq!(v.as_f64(), Some(100.0));
9521
9522        // Negative integer.
9523        let v = parse_numeric_attr("-42").unwrap();
9524        assert!(v.is_i64());
9525        assert_eq!(v.as_i64(), Some(-42));
9526
9527        assert!(parse_numeric_attr("not-a-number").is_none());
9528        assert!(parse_numeric_attr("").is_none());
9529        assert!(parse_numeric_attr("1.2.3").is_none());
9530    }
9531
9532    /// Issue #555: fractional `mediaSingle` width with non-default `layout=wide`
9533    /// must preserve both the layout and the fractional width through round-trip.
9534    #[test]
9535    fn issue_555_media_single_wide_layout_fractional_width_roundtrip() {
9536        let adf_json = r#"{
9537            "version": 1,
9538            "type": "doc",
9539            "content": [{
9540                "type": "mediaSingle",
9541                "attrs": {"layout": "wide", "width": 83.33},
9542                "content": [
9543                    {"type": "media", "attrs": {"type": "external", "url": "https://ex.com/x.png"}}
9544                ]
9545            }]
9546        }"#;
9547        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9548        let md = adf_to_markdown(&doc).unwrap();
9549        assert!(md.contains("layout=wide"), "layout must appear in md: {md}");
9550        assert!(md.contains("width=83.33"), "width must appear in md: {md}");
9551        let rt = markdown_to_adf(&md).unwrap();
9552        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9553        assert_eq!(ms_attrs["layout"], "wide");
9554        assert_eq!(ms_attrs["width"], 83.33);
9555    }
9556
9557    /// Issue #555: fractional `mediaWidth` on a Confluence file-attachment
9558    /// `mediaSingle` must round-trip (exercises the file-branch `mediaWidth`
9559    /// render path, which previously used `as_u64` and silently dropped floats).
9560    #[test]
9561    fn issue_555_file_media_single_fractional_media_width_preserved() {
9562        let adf_json = r#"{
9563            "version": 1,
9564            "type": "doc",
9565            "content": [{
9566                "type": "mediaSingle",
9567                "attrs": {"layout": "wide", "width": 66.5},
9568                "content": [
9569                    {"type": "media", "attrs": {"id": "abc", "type": "file", "collection": "c"}}
9570                ]
9571            }]
9572        }"#;
9573        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9574        let md = adf_to_markdown(&doc).unwrap();
9575        assert!(md.contains("mediaWidth=66.5"), "mediaWidth in md: {md}");
9576        let rt = markdown_to_adf(&md).unwrap();
9577        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9578        assert_eq!(ms_attrs["width"], 66.5);
9579    }
9580
9581    /// Issue #555: fractional inner `media` width/height on a file attachment
9582    /// must round-trip (exercises the file-branch inner `width`/`height` render
9583    /// path, which previously used `as_u64` and silently dropped floats).
9584    #[test]
9585    fn issue_555_file_media_fractional_inner_dimensions_preserved() {
9586        let adf_json = r#"{
9587            "version": 1,
9588            "type": "doc",
9589            "content": [{
9590                "type": "mediaSingle",
9591                "attrs": {"layout": "center"},
9592                "content": [
9593                    {"type": "media", "attrs": {"id": "abc", "type": "file", "collection": "c", "width": 1200.5, "height": 800.25}}
9594                ]
9595            }]
9596        }"#;
9597        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9598        let md = adf_to_markdown(&doc).unwrap();
9599        assert!(md.contains("width=1200.5"), "width in md: {md}");
9600        assert!(md.contains("height=800.25"), "height in md: {md}");
9601        let rt = markdown_to_adf(&md).unwrap();
9602        let media = &rt.content[0].content.as_ref().unwrap()[0];
9603        let attrs = media.attrs.as_ref().unwrap();
9604        assert_eq!(attrs["width"], 1200.5);
9605        assert_eq!(attrs["height"], 800.25);
9606    }
9607
9608    #[test]
9609    fn decisions_list() {
9610        let md = ":::decisions\n- <> Use PostgreSQL\n- <> REST API\n:::";
9611        let doc = markdown_to_adf(md).unwrap();
9612        assert_eq!(doc.content[0].node_type, "decisionList");
9613        let items = doc.content[0].content.as_ref().unwrap();
9614        assert_eq!(items.len(), 2);
9615        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DECIDED");
9616    }
9617
9618    // decisionItem is inline-only per ADF schema — its content must be
9619    // text/inline nodes, not a paragraph wrapper (issue #753).
9620    #[test]
9621    fn decision_item_content_is_inline_not_paragraph() {
9622        let md = ":::decisions\n- <> Use Rust\n:::";
9623        let doc = markdown_to_adf(md).unwrap();
9624        let items = doc.content[0].content.as_ref().unwrap();
9625        let first_child = &items[0].content.as_ref().unwrap()[0];
9626        assert_eq!(
9627            first_child.node_type, "text",
9628            "decisionItem must contain inline nodes directly, not a paragraph wrapper"
9629        );
9630        assert_eq!(first_child.text.as_deref(), Some("Use Rust"));
9631    }
9632
9633    #[test]
9634    fn adf_decisions_to_markdown() {
9635        let doc = AdfDocument {
9636            version: 1,
9637            doc_type: "doc".to_string(),
9638            content: vec![AdfNode::decision_list(vec![AdfNode::decision_item(
9639                "DECIDED",
9640                vec![AdfNode::paragraph(vec![AdfNode::text("Use PostgreSQL")])],
9641            )])],
9642        };
9643        let md = adf_to_markdown(&doc).unwrap();
9644        assert!(md.contains(":::decisions"));
9645        assert!(md.contains("- <> Use PostgreSQL"));
9646    }
9647
9648    #[test]
9649    fn bodied_extension_container() {
9650        let md = ":::extension{type=com.forge key=my-macro}\nContent.\n:::";
9651        let doc = markdown_to_adf(md).unwrap();
9652        assert_eq!(doc.content[0].node_type, "bodiedExtension");
9653        assert_eq!(
9654            doc.content[0].attrs.as_ref().unwrap()["extensionType"],
9655            "com.forge"
9656        );
9657    }
9658
9659    #[test]
9660    fn adf_bodied_extension_to_markdown() {
9661        let doc = AdfDocument {
9662            version: 1,
9663            doc_type: "doc".to_string(),
9664            content: vec![AdfNode::bodied_extension(
9665                "com.forge",
9666                "my-macro",
9667                vec![AdfNode::paragraph(vec![AdfNode::text("Content.")])],
9668            )],
9669        };
9670        let md = adf_to_markdown(&doc).unwrap();
9671        assert!(md.contains(":::extension{type=com.forge key=my-macro}"));
9672        assert!(md.contains("Content."));
9673    }
9674
9675    // ── Leaf block directive tests (Tier 3) ──────────────────────────
9676
9677    #[test]
9678    fn leaf_block_card() {
9679        let doc = markdown_to_adf("::card[https://example.com/browse/PROJ-123]").unwrap();
9680        assert_eq!(doc.content[0].node_type, "blockCard");
9681        assert_eq!(
9682            doc.content[0].attrs.as_ref().unwrap()["url"],
9683            "https://example.com/browse/PROJ-123"
9684        );
9685    }
9686
9687    #[test]
9688    fn adf_block_card_to_markdown() {
9689        let doc = AdfDocument {
9690            version: 1,
9691            doc_type: "doc".to_string(),
9692            content: vec![AdfNode::block_card("https://example.com/browse/PROJ-123")],
9693        };
9694        let md = adf_to_markdown(&doc).unwrap();
9695        assert!(md.contains("::card[https://example.com/browse/PROJ-123]"));
9696    }
9697
9698    #[test]
9699    fn round_trip_block_card() {
9700        let md = "::card[https://example.com/browse/PROJ-123]\n";
9701        let doc = markdown_to_adf(md).unwrap();
9702        let result = adf_to_markdown(&doc).unwrap();
9703        assert!(result.contains("::card[https://example.com/browse/PROJ-123]"));
9704    }
9705
9706    #[test]
9707    fn leaf_embed_card() {
9708        let doc =
9709            markdown_to_adf("::embed[https://figma.com/file/abc]{layout=wide width=80}").unwrap();
9710        assert_eq!(doc.content[0].node_type, "embedCard");
9711        let attrs = doc.content[0].attrs.as_ref().unwrap();
9712        assert_eq!(attrs["url"], "https://figma.com/file/abc");
9713        assert_eq!(attrs["layout"], "wide");
9714        assert_eq!(attrs["width"], 80.0);
9715    }
9716
9717    #[test]
9718    fn leaf_embed_card_with_original_height() {
9719        let doc = markdown_to_adf(
9720            "::embed[https://example.com]{layout=center originalHeight=732 width=100}",
9721        )
9722        .unwrap();
9723        assert_eq!(doc.content[0].node_type, "embedCard");
9724        let attrs = doc.content[0].attrs.as_ref().unwrap();
9725        assert_eq!(attrs["url"], "https://example.com");
9726        assert_eq!(attrs["layout"], "center");
9727        assert_eq!(attrs["originalHeight"], 732.0);
9728        assert_eq!(attrs["width"], 100.0);
9729    }
9730
9731    #[test]
9732    fn adf_embed_card_to_markdown() {
9733        let doc = AdfDocument {
9734            version: 1,
9735            doc_type: "doc".to_string(),
9736            content: vec![AdfNode::embed_card(
9737                "https://figma.com/file/abc",
9738                Some("wide"),
9739                None,
9740                Some(80.0),
9741            )],
9742        };
9743        let md = adf_to_markdown(&doc).unwrap();
9744        assert!(md.contains("::embed[https://figma.com/file/abc]{layout=wide width=80}"));
9745    }
9746
9747    #[test]
9748    fn adf_embed_card_original_height_to_markdown() {
9749        let doc = AdfDocument {
9750            version: 1,
9751            doc_type: "doc".to_string(),
9752            content: vec![AdfNode::embed_card(
9753                "https://example.com",
9754                Some("center"),
9755                Some(732.0),
9756                Some(100.0),
9757            )],
9758        };
9759        let md = adf_to_markdown(&doc).unwrap();
9760        assert!(
9761            md.contains("::embed[https://example.com]{layout=center originalHeight=732 width=100}"),
9762            "expected originalHeight and width in md: {md}"
9763        );
9764    }
9765
9766    #[test]
9767    fn embed_card_roundtrip_with_all_attrs() {
9768        let adf_json = r#"{"version":1,"type":"doc","content":[{
9769            "type":"embedCard",
9770            "attrs":{"layout":"center","originalHeight":732.0,"url":"https://example.com","width":100.0}
9771        }]}"#;
9772        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9773        let md = adf_to_markdown(&doc).unwrap();
9774        assert!(
9775            md.contains("originalHeight=732"),
9776            "originalHeight missing from md: {md}"
9777        );
9778        assert!(md.contains("width=100"), "width missing from md: {md}");
9779        let rt = markdown_to_adf(&md).unwrap();
9780        let attrs = rt.content[0].attrs.as_ref().unwrap();
9781        assert_eq!(attrs["originalHeight"], 732.0);
9782        assert_eq!(attrs["width"], 100.0);
9783        assert_eq!(attrs["layout"], "center");
9784        assert_eq!(attrs["url"], "https://example.com");
9785    }
9786
9787    #[test]
9788    fn embed_card_fractional_dimensions() {
9789        let doc = AdfDocument {
9790            version: 1,
9791            doc_type: "doc".to_string(),
9792            content: vec![AdfNode::embed_card(
9793                "https://example.com",
9794                Some("center"),
9795                Some(732.5),
9796                Some(99.9),
9797            )],
9798        };
9799        let md = adf_to_markdown(&doc).unwrap();
9800        assert!(
9801            md.contains("originalHeight=732.5"),
9802            "fractional originalHeight missing: {md}"
9803        );
9804        assert!(md.contains("width=99.9"), "fractional width missing: {md}");
9805        let rt = markdown_to_adf(&md).unwrap();
9806        let attrs = rt.content[0].attrs.as_ref().unwrap();
9807        assert_eq!(attrs["originalHeight"], 732.5);
9808        assert_eq!(attrs["width"], 99.9);
9809    }
9810
9811    #[test]
9812    fn embed_card_integer_width_in_json() {
9813        // JSON integer (not float) should also be extracted via as_f64()
9814        let adf_json = r#"{"version":1,"type":"doc","content":[{
9815            "type":"embedCard",
9816            "attrs":{"url":"https://example.com","width":100}
9817        }]}"#;
9818        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9819        let md = adf_to_markdown(&doc).unwrap();
9820        assert!(
9821            md.contains("width=100"),
9822            "integer width missing from md: {md}"
9823        );
9824        let rt = markdown_to_adf(&md).unwrap();
9825        assert_eq!(rt.content[0].attrs.as_ref().unwrap()["width"], 100.0);
9826    }
9827
9828    #[test]
9829    fn embed_card_only_original_height() {
9830        // originalHeight without width
9831        let adf_json = r#"{"version":1,"type":"doc","content":[{
9832            "type":"embedCard",
9833            "attrs":{"url":"https://example.com","originalHeight":500.0}
9834        }]}"#;
9835        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9836        let md = adf_to_markdown(&doc).unwrap();
9837        assert!(
9838            md.contains("originalHeight=500"),
9839            "originalHeight missing: {md}"
9840        );
9841        assert!(!md.contains("width="), "width should not appear: {md}");
9842        let rt = markdown_to_adf(&md).unwrap();
9843        let attrs = rt.content[0].attrs.as_ref().unwrap();
9844        assert_eq!(attrs["originalHeight"], 500.0);
9845        assert!(attrs.get("width").is_none());
9846    }
9847
9848    #[test]
9849    fn leaf_void_extension() {
9850        let doc = markdown_to_adf("::extension{type=com.atlassian.macro key=jira-chart}").unwrap();
9851        assert_eq!(doc.content[0].node_type, "extension");
9852        assert_eq!(
9853            doc.content[0].attrs.as_ref().unwrap()["extensionType"],
9854            "com.atlassian.macro"
9855        );
9856        assert_eq!(
9857            doc.content[0].attrs.as_ref().unwrap()["extensionKey"],
9858            "jira-chart"
9859        );
9860    }
9861
9862    #[test]
9863    fn adf_void_extension_to_markdown() {
9864        let doc = AdfDocument {
9865            version: 1,
9866            doc_type: "doc".to_string(),
9867            content: vec![AdfNode::extension(
9868                "com.atlassian.macro",
9869                "jira-chart",
9870                None,
9871            )],
9872        };
9873        let md = adf_to_markdown(&doc).unwrap();
9874        assert!(md.contains("::extension{type=com.atlassian.macro key=jira-chart}"));
9875    }
9876
9877    // ── Bare URL autolink tests ──────────────────────────────────────
9878
9879    #[test]
9880    fn bare_url_autolink() {
9881        let doc = markdown_to_adf("Visit https://example.com today").unwrap();
9882        let content = doc.content[0].content.as_ref().unwrap();
9883        assert_eq!(content[0].text.as_deref(), Some("Visit "));
9884        assert_eq!(content[1].node_type, "inlineCard");
9885        assert_eq!(
9886            content[1].attrs.as_ref().unwrap()["url"],
9887            "https://example.com"
9888        );
9889        assert_eq!(content[2].text.as_deref(), Some(" today"));
9890    }
9891
9892    #[test]
9893    fn bare_url_strips_trailing_punctuation() {
9894        let doc = markdown_to_adf("See https://example.com.").unwrap();
9895        let content = doc.content[0].content.as_ref().unwrap();
9896        assert_eq!(
9897            content[1].attrs.as_ref().unwrap()["url"],
9898            "https://example.com"
9899        );
9900    }
9901
9902    #[test]
9903    fn bare_url_round_trip() {
9904        let doc = markdown_to_adf("Visit https://example.com/path today").unwrap();
9905        let md = adf_to_markdown(&doc).unwrap();
9906        assert!(md.contains(":card[https://example.com/path]"));
9907    }
9908
9909    // ── Issue #475: plain-text URL must not become inlineCard ─────────
9910
9911    #[test]
9912    fn plain_text_url_round_trips_as_text() {
9913        // A text node whose content is a bare URL (no link mark) must
9914        // survive ADF→JFM→ADF as a text node, not an inlineCard.
9915        let adf_json = r#"{
9916            "version": 1,
9917            "type": "doc",
9918            "content": [{
9919                "type": "paragraph",
9920                "content": [
9921                    {"type": "text", "text": "https://example.com/some/path/to/resource"}
9922                ]
9923            }]
9924        }"#;
9925        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9926        let jfm = adf_to_markdown(&adf).unwrap();
9927        let roundtripped = markdown_to_adf(&jfm).unwrap();
9928        let content = roundtripped.content[0].content.as_ref().unwrap();
9929        assert_eq!(content.len(), 1, "should be a single node");
9930        assert_eq!(content[0].node_type, "text");
9931        assert_eq!(
9932            content[0].text.as_deref(),
9933            Some("https://example.com/some/path/to/resource")
9934        );
9935    }
9936
9937    #[test]
9938    fn url_text_with_link_mark_round_trips_as_text_node() {
9939        // Issue #523: A text node whose content is a URL with a link mark
9940        // (href differs by trailing slash) must round-trip as text+link,
9941        // not become an inlineCard.
9942        let adf_json = r#"{
9943            "version": 1,
9944            "type": "doc",
9945            "content": [{
9946                "type": "paragraph",
9947                "content": [{
9948                    "type": "text",
9949                    "text": "https://octopz.example.com",
9950                    "marks": [{"type": "link", "attrs": {"href": "https://octopz.example.com/"}}]
9951                }]
9952            }]
9953        }"#;
9954        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9955        let jfm = adf_to_markdown(&adf).unwrap();
9956        let roundtripped = markdown_to_adf(&jfm).unwrap();
9957        let content = roundtripped.content[0].content.as_ref().unwrap();
9958        assert_eq!(content.len(), 1, "should be a single node");
9959        assert_eq!(content[0].node_type, "text", "must be text, not inlineCard");
9960        assert_eq!(
9961            content[0].text.as_deref(),
9962            Some("https://octopz.example.com")
9963        );
9964        let mark = &content[0].marks.as_ref().unwrap()[0];
9965        assert_eq!(mark.mark_type, "link");
9966        assert_eq!(
9967            mark.attrs.as_ref().unwrap()["href"],
9968            "https://octopz.example.com/"
9969        );
9970    }
9971
9972    #[test]
9973    fn url_text_with_exact_link_mark_round_trips() {
9974        // Variant: text and href are identical (no trailing slash difference).
9975        let adf_json = r#"{
9976            "version": 1,
9977            "type": "doc",
9978            "content": [{
9979                "type": "paragraph",
9980                "content": [{
9981                    "type": "text",
9982                    "text": "https://example.com/path",
9983                    "marks": [{"type": "link", "attrs": {"href": "https://example.com/path"}}]
9984                }]
9985            }]
9986        }"#;
9987        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
9988        let jfm = adf_to_markdown(&adf).unwrap();
9989        let roundtripped = markdown_to_adf(&jfm).unwrap();
9990        let content = roundtripped.content[0].content.as_ref().unwrap();
9991        assert_eq!(content.len(), 1, "should be a single node");
9992        assert_eq!(content[0].node_type, "text");
9993        assert_eq!(content[0].text.as_deref(), Some("https://example.com/path"));
9994        let mark = &content[0].marks.as_ref().unwrap()[0];
9995        assert_eq!(mark.mark_type, "link");
9996    }
9997
9998    #[test]
9999    fn plain_text_url_amid_text_round_trips() {
10000        // URL embedded in surrounding text, without link mark.
10001        let adf_json = r#"{
10002            "version": 1,
10003            "type": "doc",
10004            "content": [{
10005                "type": "paragraph",
10006                "content": [
10007                    {"type": "text", "text": "see https://example.com for info"}
10008                ]
10009            }]
10010        }"#;
10011        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10012        let jfm = adf_to_markdown(&adf).unwrap();
10013        let roundtripped = markdown_to_adf(&jfm).unwrap();
10014        let content = roundtripped.content[0].content.as_ref().unwrap();
10015        assert_eq!(content.len(), 1);
10016        assert_eq!(content[0].node_type, "text");
10017        assert_eq!(
10018            content[0].text.as_deref(),
10019            Some("see https://example.com for info")
10020        );
10021    }
10022
10023    #[test]
10024    fn plain_text_multiple_urls_round_trips() {
10025        let adf_json = r#"{
10026            "version": 1,
10027            "type": "doc",
10028            "content": [{
10029                "type": "paragraph",
10030                "content": [
10031                    {"type": "text", "text": "http://a.com and https://b.com"}
10032                ]
10033            }]
10034        }"#;
10035        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10036        let jfm = adf_to_markdown(&adf).unwrap();
10037        let roundtripped = markdown_to_adf(&jfm).unwrap();
10038        let content = roundtripped.content[0].content.as_ref().unwrap();
10039        assert_eq!(content.len(), 1);
10040        assert_eq!(content[0].node_type, "text");
10041        assert_eq!(
10042            content[0].text.as_deref(),
10043            Some("http://a.com and https://b.com")
10044        );
10045    }
10046
10047    #[test]
10048    fn plain_text_http_prefix_no_url_unchanged() {
10049        // "http" without "://" should not be escaped or altered.
10050        let adf_json = r#"{
10051            "version": 1,
10052            "type": "doc",
10053            "content": [{
10054                "type": "paragraph",
10055                "content": [
10056                    {"type": "text", "text": "the http header is important"}
10057                ]
10058            }]
10059        }"#;
10060        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10061        let jfm = adf_to_markdown(&adf).unwrap();
10062        let roundtripped = markdown_to_adf(&jfm).unwrap();
10063        let content = roundtripped.content[0].content.as_ref().unwrap();
10064        assert_eq!(
10065            content[0].text.as_deref(),
10066            Some("the http header is important")
10067        );
10068    }
10069
10070    #[test]
10071    fn linked_url_text_round_trips() {
10072        // A text node that is exactly a URL with a link mark pointing to the
10073        // same URL must round-trip as a single text node with a link mark
10074        // (no inlineCard, no lost/split content).
10075        let adf_json = r#"{
10076            "version": 1,
10077            "type": "doc",
10078            "content": [{
10079                "type": "paragraph",
10080                "content": [{
10081                    "type": "text",
10082                    "text": "https://example.com",
10083                    "marks": [{"type": "link", "attrs": {"href": "https://example.com"}}]
10084                }]
10085            }]
10086        }"#;
10087        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10088        let jfm = adf_to_markdown(&adf).unwrap();
10089        let roundtripped = markdown_to_adf(&jfm).unwrap();
10090        let content = roundtripped.content[0].content.as_ref().unwrap();
10091        assert_eq!(content.len(), 1);
10092        assert_eq!(content[0].node_type, "text");
10093        assert_eq!(content[0].text.as_deref(), Some("https://example.com"));
10094        let mark = &content[0].marks.as_ref().unwrap()[0];
10095        assert_eq!(mark.mark_type, "link");
10096        assert_eq!(mark.attrs.as_ref().unwrap()["href"], "https://example.com");
10097    }
10098
10099    // ── Issue #493: bracket-link ambiguity ─────────────────────────────
10100
10101    #[test]
10102    fn escape_link_brackets_unit() {
10103        assert_eq!(escape_link_brackets("hello"), "hello");
10104        assert_eq!(escape_link_brackets("["), "\\[");
10105        assert_eq!(escape_link_brackets("]"), "\\]");
10106        assert_eq!(escape_link_brackets("[PROJ-456]"), "\\[PROJ-456\\]");
10107        assert_eq!(escape_link_brackets("a[b]c"), "a\\[b\\]c");
10108    }
10109
10110    #[test]
10111    fn bracket_text_with_link_mark_escapes_brackets() {
10112        // A text node whose content is "[" with a link mark should escape
10113        // the bracket so it does not create ambiguous markdown link syntax.
10114        let doc = AdfDocument {
10115            version: 1,
10116            doc_type: "doc".to_string(),
10117            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10118                "[",
10119                vec![AdfMark::link("https://example.com")],
10120            )])],
10121        };
10122        let md = adf_to_markdown(&doc).unwrap();
10123        assert_eq!(md.trim(), "[\\[](https://example.com)");
10124    }
10125
10126    #[test]
10127    fn bracket_text_with_link_mark_round_trips() {
10128        // Issue #493 reproducer: adjacent text nodes sharing a link mark
10129        // where the first node's content is "[".
10130        let adf_json = r#"{
10131            "type": "doc",
10132            "version": 1,
10133            "content": [{
10134                "type": "paragraph",
10135                "content": [
10136                    {
10137                        "type": "text",
10138                        "text": "[",
10139                        "marks": [{"type": "link", "attrs": {"href": "https://example.com/ticket/123"}}]
10140                    },
10141                    {
10142                        "type": "text",
10143                        "text": "PROJ-456] Fix the auth bug",
10144                        "marks": [
10145                            {"type": "underline"},
10146                            {"type": "link", "attrs": {"href": "https://example.com/ticket/123"}}
10147                        ]
10148                    }
10149                ]
10150            }]
10151        }"#;
10152        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10153        let jfm = adf_to_markdown(&adf).unwrap();
10154
10155        // The markdown should contain escaped brackets inside the link
10156        assert!(jfm.contains("\\["), "opening bracket should be escaped");
10157
10158        // Round-trip: both text nodes must survive with link marks
10159        let rt = markdown_to_adf(&jfm).unwrap();
10160        let content = rt.content[0].content.as_ref().unwrap();
10161
10162        // All text nodes that were part of the link must still carry a link mark
10163        let link_nodes: Vec<_> = content
10164            .iter()
10165            .filter(|n| {
10166                n.marks
10167                    .as_ref()
10168                    .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "link"))
10169            })
10170            .collect();
10171        assert!(
10172            !link_nodes.is_empty(),
10173            "link mark must be preserved on round-trip"
10174        );
10175
10176        // The combined text across all nodes should contain the original content
10177        let all_text: String = content.iter().filter_map(|n| n.text.as_deref()).collect();
10178        assert!(
10179            all_text.contains('['),
10180            "literal '[' must survive round-trip"
10181        );
10182        assert!(
10183            all_text.contains("PROJ-456]"),
10184            "continuation text must survive round-trip"
10185        );
10186    }
10187
10188    #[test]
10189    fn closing_bracket_in_link_text_round_trips() {
10190        // A text node containing "]" inside a link should be escaped and
10191        // survive round-trip without breaking the link syntax.
10192        let doc = AdfDocument {
10193            version: 1,
10194            doc_type: "doc".to_string(),
10195            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10196                "item]",
10197                vec![AdfMark::link("https://example.com")],
10198            )])],
10199        };
10200        let md = adf_to_markdown(&doc).unwrap();
10201        assert_eq!(md.trim(), "[item\\]](https://example.com)");
10202
10203        let rt = markdown_to_adf(&md).unwrap();
10204        let content = rt.content[0].content.as_ref().unwrap();
10205        assert_eq!(content[0].text.as_deref(), Some("item]"));
10206        assert!(content[0]
10207            .marks
10208            .as_ref()
10209            .unwrap()
10210            .iter()
10211            .any(|m| m.mark_type == "link"));
10212    }
10213
10214    #[test]
10215    fn brackets_in_link_text_round_trip() {
10216        // Text containing both [ and ] inside a link should round-trip.
10217        let doc = AdfDocument {
10218            version: 1,
10219            doc_type: "doc".to_string(),
10220            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10221                "[PROJ-123]",
10222                vec![AdfMark::link("https://example.com")],
10223            )])],
10224        };
10225        let md = adf_to_markdown(&doc).unwrap();
10226        assert_eq!(md.trim(), "[\\[PROJ-123\\]](https://example.com)");
10227
10228        let rt = markdown_to_adf(&md).unwrap();
10229        let content = rt.content[0].content.as_ref().unwrap();
10230        assert_eq!(content[0].text.as_deref(), Some("[PROJ-123]"));
10231        assert!(content[0]
10232            .marks
10233            .as_ref()
10234            .unwrap()
10235            .iter()
10236            .any(|m| m.mark_type == "link"));
10237    }
10238
10239    #[test]
10240    fn plain_text_brackets_not_escaped() {
10241        // Brackets in plain text (no link mark) must NOT be escaped.
10242        let doc = AdfDocument {
10243            version: 1,
10244            doc_type: "doc".to_string(),
10245            content: vec![AdfNode::paragraph(vec![AdfNode::text(
10246                "see [PROJ-123] for details",
10247            )])],
10248        };
10249        let md = adf_to_markdown(&doc).unwrap();
10250        assert_eq!(md.trim(), "see [PROJ-123] for details");
10251    }
10252
10253    #[test]
10254    fn link_with_no_brackets_unchanged() {
10255        // A normal link with no brackets in the text should be unaffected.
10256        let doc = AdfDocument {
10257            version: 1,
10258            doc_type: "doc".to_string(),
10259            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10260                "click here",
10261                vec![AdfMark::link("https://example.com")],
10262            )])],
10263        };
10264        let md = adf_to_markdown(&doc).unwrap();
10265        assert_eq!(md.trim(), "[click here](https://example.com)");
10266    }
10267
10268    // ── Issue #551: URL brackets in link-marked text round-trip ────────
10269
10270    #[test]
10271    fn url_with_brackets_as_link_text_round_trips() {
10272        // Issue #551: a text node whose content is a URL containing square
10273        // brackets and which carries a link mark must round-trip verbatim.
10274        // Previously the URL-as-link-text fast path preserved `\[` and `\]`
10275        // escapes in the emitted text, corrupting the text content.
10276        let href = "https://example.com/dashboard?filter[0]=active&filter[1]=pending";
10277        let doc = AdfDocument {
10278            version: 1,
10279            doc_type: "doc".to_string(),
10280            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10281                href,
10282                vec![AdfMark::link(href)],
10283            )])],
10284        };
10285        let md = adf_to_markdown(&doc).unwrap();
10286        let rt = markdown_to_adf(&md).unwrap();
10287        let content = rt.content[0].content.as_ref().unwrap();
10288        assert_eq!(content.len(), 1);
10289        assert_eq!(content[0].node_type, "text");
10290        assert_eq!(content[0].text.as_deref(), Some(href));
10291        let mark = &content[0].marks.as_ref().unwrap()[0];
10292        assert_eq!(mark.mark_type, "link");
10293        assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10294    }
10295
10296    #[test]
10297    fn url_with_brackets_embedded_in_link_text_round_trips() {
10298        // Issue #551 updated reproducer: a link-marked text node containing
10299        // both prose and an embedded URL with brackets must round-trip
10300        // without the embedded URL being detected as a bare-URL inlineCard
10301        // or the brackets terminating the link syntax early.  This mirrors
10302        // the comment reproducer which uses an ellipsis character between
10303        // the brackets and a distinct href value.
10304        let href = "https://example.com/logs?query=service%20environment%20data&from=100&to=200";
10305        let text =
10306            "See the logs: https://example.com/logs?query=service[\u{2026}]data&from=100&to=200";
10307        let doc = AdfDocument {
10308            version: 1,
10309            doc_type: "doc".to_string(),
10310            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10311                text,
10312                vec![AdfMark::link(href)],
10313            )])],
10314        };
10315        let md = adf_to_markdown(&doc).unwrap();
10316        let rt = markdown_to_adf(&md).unwrap();
10317        let content = rt.content[0].content.as_ref().unwrap();
10318        assert_eq!(content.len(), 1, "content split unexpectedly: {content:?}");
10319        assert_eq!(content[0].node_type, "text");
10320        assert_eq!(content[0].text.as_deref(), Some(text));
10321        let mark = &content[0].marks.as_ref().unwrap()[0];
10322        assert_eq!(mark.mark_type, "link");
10323        assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10324    }
10325
10326    #[test]
10327    fn url_with_brackets_plain_text_round_trips() {
10328        // Issue #551 original reproducer: plain text with an embedded URL
10329        // that contains square brackets must round-trip verbatim.
10330        let text =
10331            "See the dashboard: https://example.com/dashboard?filter[0]=active&filter[1]=pending";
10332        let doc = AdfDocument {
10333            version: 1,
10334            doc_type: "doc".to_string(),
10335            content: vec![AdfNode::paragraph(vec![AdfNode::text(text)])],
10336        };
10337        let md = adf_to_markdown(&doc).unwrap();
10338        let rt = markdown_to_adf(&md).unwrap();
10339        let content = rt.content[0].content.as_ref().unwrap();
10340        assert_eq!(content.len(), 1);
10341        assert_eq!(content[0].node_type, "text");
10342        assert_eq!(content[0].text.as_deref(), Some(text));
10343        assert!(content[0].marks.is_none());
10344    }
10345
10346    #[test]
10347    fn url_with_link_mark_embedded_no_brackets_round_trips() {
10348        // Regression guard: embedding a bare URL inside link-marked text
10349        // (no brackets) must not create an inlineCard on round-trip.
10350        let href = "https://example.com/";
10351        let text = "See https://example.com/ for more";
10352        let doc = AdfDocument {
10353            version: 1,
10354            doc_type: "doc".to_string(),
10355            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10356                text,
10357                vec![AdfMark::link(href)],
10358            )])],
10359        };
10360        let md = adf_to_markdown(&doc).unwrap();
10361        let rt = markdown_to_adf(&md).unwrap();
10362        let content = rt.content[0].content.as_ref().unwrap();
10363        assert_eq!(content.len(), 1);
10364        assert_eq!(content[0].node_type, "text");
10365        assert_eq!(content[0].text.as_deref(), Some(text));
10366        let mark = &content[0].marks.as_ref().unwrap()[0];
10367        assert_eq!(mark.mark_type, "link");
10368        assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10369    }
10370
10371    #[test]
10372    fn nested_brackets_in_link_text_round_trip() {
10373        // Regression guard: nested brackets in link-marked text must
10374        // round-trip without corrupting the content.
10375        let href = "https://x.com";
10376        let text = "foo [a[b]c] bar";
10377        let doc = AdfDocument {
10378            version: 1,
10379            doc_type: "doc".to_string(),
10380            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10381                text,
10382                vec![AdfMark::link(href)],
10383            )])],
10384        };
10385        let md = adf_to_markdown(&doc).unwrap();
10386        let rt = markdown_to_adf(&md).unwrap();
10387        let content = rt.content[0].content.as_ref().unwrap();
10388        assert_eq!(content.len(), 1);
10389        assert_eq!(content[0].node_type, "text");
10390        assert_eq!(content[0].text.as_deref(), Some(text));
10391    }
10392
10393    #[test]
10394    fn bracket_url_bracket_in_link_text_round_trips() {
10395        // Regression guard: a link-marked text containing brackets on both
10396        // sides of an embedded URL (with brackets of its own) must
10397        // round-trip intact.  This exercises interaction between the
10398        // URL-as-link-text fast path, bare-URL detection, and bracket
10399        // escape handling.
10400        let href = "https://y.com";
10401        let text = "[see https://x.com/a[0]=1 here]";
10402        let doc = AdfDocument {
10403            version: 1,
10404            doc_type: "doc".to_string(),
10405            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10406                text,
10407                vec![AdfMark::link(href)],
10408            )])],
10409        };
10410        let md = adf_to_markdown(&doc).unwrap();
10411        let rt = markdown_to_adf(&md).unwrap();
10412        let content = rt.content[0].content.as_ref().unwrap();
10413        assert_eq!(content.len(), 1);
10414        assert_eq!(content[0].node_type, "text");
10415        assert_eq!(content[0].text.as_deref(), Some(text));
10416        let mark = &content[0].marks.as_ref().unwrap()[0];
10417        assert_eq!(mark.mark_type, "link");
10418        assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10419    }
10420
10421    #[test]
10422    fn escape_bare_urls_applied_inside_link_text() {
10423        // White-box: when a text node carries a link mark, bare URLs in the
10424        // text must still be escaped with `\h` so the parser does not
10425        // auto-link them into an inlineCard inside the link.  Without this,
10426        // round-trip of link-marked prose containing an embedded URL
10427        // silently corrupts on re-parse (issue #551).
10428        let doc = AdfDocument {
10429            version: 1,
10430            doc_type: "doc".to_string(),
10431            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10432                "See https://example.com/",
10433                vec![AdfMark::link("https://target.example.com/")],
10434            )])],
10435        };
10436        let md = adf_to_markdown(&doc).unwrap();
10437        assert!(
10438            md.contains(r"\https://example.com/"),
10439            "bare URL inside link text must be escaped, got: {md}"
10440        );
10441    }
10442
10443    #[test]
10444    fn inline_card_still_round_trips() {
10445        // An actual inlineCard node should still round-trip correctly
10446        // (it uses :card[url] syntax, not bare URL).
10447        let adf_json = r#"{
10448            "version": 1,
10449            "type": "doc",
10450            "content": [{
10451                "type": "paragraph",
10452                "content": [
10453                    {"type": "inlineCard", "attrs": {"url": "https://example.com/page"}}
10454                ]
10455            }]
10456        }"#;
10457        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10458        let jfm = adf_to_markdown(&adf).unwrap();
10459        assert!(jfm.contains(":card[https://example.com/page]"));
10460        let roundtripped = markdown_to_adf(&jfm).unwrap();
10461        let content = roundtripped.content[0].content.as_ref().unwrap();
10462        assert_eq!(content[0].node_type, "inlineCard");
10463        assert_eq!(
10464            content[0].attrs.as_ref().unwrap()["url"],
10465            "https://example.com/page"
10466        );
10467    }
10468
10469    // ── Issue #553: inlineCard round-trip with problematic URLs ───────
10470
10471    #[test]
10472    fn url_safe_in_bracket_content_balanced() {
10473        // Balanced brackets — depth never returns to zero mid-string.
10474        assert!(url_safe_in_bracket_content("https://example.com"));
10475        assert!(url_safe_in_bracket_content("https://example.com/[id]"));
10476        assert!(url_safe_in_bracket_content("a[b[c]d]e"));
10477        assert!(url_safe_in_bracket_content(""));
10478    }
10479
10480    #[test]
10481    fn url_safe_in_bracket_content_unbalanced() {
10482        // A `]` with no prior `[` would close `:card[...]` early.
10483        assert!(!url_safe_in_bracket_content("a]b"));
10484        assert!(!url_safe_in_bracket_content("https://example.com/path]end"));
10485        // Embedded newline breaks inline directive parsing.
10486        assert!(!url_safe_in_bracket_content("a\nb"));
10487    }
10488
10489    #[test]
10490    fn inline_card_url_with_closing_bracket_round_trip() {
10491        // Issue #553 defensive fix: a URL that contains `]` (unbalanced) must
10492        // round-trip without truncation.  The renderer must switch to the
10493        // quoted attribute form `:card[]{url="..."}` so the parser's
10494        // depth-based bracket matcher does not terminate the directive early.
10495        let adf_json = r#"{
10496            "version": 1,
10497            "type": "doc",
10498            "content": [{
10499                "type": "paragraph",
10500                "content": [
10501                    {"type": "text", "text": "See: "},
10502                    {"type": "inlineCard", "attrs": {"url": "https://example.com/path]end/?q=1"}}
10503                ]
10504            }]
10505        }"#;
10506        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10507        let jfm = adf_to_markdown(&adf).unwrap();
10508        assert!(
10509            jfm.contains(r#":card[]{url="https://example.com/path]end/?q=1"}"#),
10510            "expected attr-form for URL with `]`, got: {jfm}"
10511        );
10512        let rt = markdown_to_adf(&jfm).unwrap();
10513        let content = rt.content[0].content.as_ref().unwrap();
10514        assert_eq!(content.len(), 2, "expected 2 inline nodes, got {content:?}");
10515        assert_eq!(content[0].node_type, "text");
10516        assert_eq!(content[0].text.as_deref(), Some("See: "));
10517        assert_eq!(content[1].node_type, "inlineCard");
10518        assert_eq!(
10519            content[1].attrs.as_ref().unwrap()["url"],
10520            "https://example.com/path]end/?q=1"
10521        );
10522    }
10523
10524    #[test]
10525    fn inline_card_url_with_closing_bracket_preserves_local_id() {
10526        // Attr-form `:card[]{url=... localId=...}` must preserve localId too.
10527        let adf_json = r#"{
10528            "version": 1,
10529            "type": "doc",
10530            "content": [{
10531                "type": "paragraph",
10532                "content": [
10533                    {"type": "inlineCard", "attrs": {
10534                        "url": "https://example.com/a]b",
10535                        "localId": "c-77"
10536                    }}
10537                ]
10538            }]
10539        }"#;
10540        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10541        let jfm = adf_to_markdown(&adf).unwrap();
10542        assert!(
10543            jfm.contains(r#"url="https://example.com/a]b""#),
10544            "jfm: {jfm}"
10545        );
10546        assert!(jfm.contains("localId=c-77"), "jfm: {jfm}");
10547        let rt = markdown_to_adf(&jfm).unwrap();
10548        let card = &rt.content[0].content.as_ref().unwrap()[0];
10549        assert_eq!(card.node_type, "inlineCard");
10550        assert_eq!(
10551            card.attrs.as_ref().unwrap()["url"],
10552            "https://example.com/a]b"
10553        );
10554        assert_eq!(card.attrs.as_ref().unwrap()["localId"], "c-77");
10555    }
10556
10557    #[test]
10558    fn block_card_url_with_closing_bracket_round_trip() {
10559        // Same defensive fix applied to the leaf directive `::card`.
10560        let adf_json = r#"{
10561            "version": 1,
10562            "type": "doc",
10563            "content": [
10564                {"type": "blockCard", "attrs": {"url": "https://example.com/path]end"}}
10565            ]
10566        }"#;
10567        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10568        let jfm = adf_to_markdown(&adf).unwrap();
10569        assert!(
10570            jfm.contains(r#"::card[]{url="https://example.com/path]end"}"#),
10571            "expected attr-form for blockCard with `]`, got: {jfm}"
10572        );
10573        let rt = markdown_to_adf(&jfm).unwrap();
10574        assert_eq!(rt.content[0].node_type, "blockCard");
10575        assert_eq!(
10576            rt.content[0].attrs.as_ref().unwrap()["url"],
10577            "https://example.com/path]end"
10578        );
10579    }
10580
10581    #[test]
10582    fn block_card_attr_form_parses_without_renderer() {
10583        // Directly parsing `::card[]{url="..."}` exercises the attr-URL
10584        // fallback in the leaf-directive dispatcher (covers the `url` lookup
10585        // path independently of the ADF→JFM renderer).
10586        let doc = markdown_to_adf(r#"::card[]{url="https://example.com/a"}"#).unwrap();
10587        assert_eq!(doc.content[0].node_type, "blockCard");
10588        assert_eq!(
10589            doc.content[0].attrs.as_ref().unwrap()["url"],
10590            "https://example.com/a"
10591        );
10592    }
10593
10594    #[test]
10595    fn block_card_attr_form_url_overrides_content() {
10596        // When both bracket-content and `url=` attribute are present on
10597        // `::card`, the attribute wins.  Mirrors the inline-directive
10598        // behaviour and keeps hand-edited JFM forgiving.
10599        let doc =
10600            markdown_to_adf(r#"::card[https://old.example.com]{url="https://new.example.com"}"#)
10601                .unwrap();
10602        assert_eq!(doc.content[0].node_type, "blockCard");
10603        assert_eq!(
10604            doc.content[0].attrs.as_ref().unwrap()["url"],
10605            "https://new.example.com"
10606        );
10607    }
10608
10609    #[test]
10610    fn block_card_attr_form_with_layout_and_width() {
10611        // Attr-URL form combined with layout/width attrs — ensures all
10612        // sibling attrs still pass through after the URL lookup.
10613        let doc =
10614            markdown_to_adf(r#"::card[]{url="https://example.com/a]b" layout=wide width=80}"#)
10615                .unwrap();
10616        let attrs = doc.content[0].attrs.as_ref().unwrap();
10617        assert_eq!(attrs["url"], "https://example.com/a]b");
10618        assert_eq!(attrs["layout"], "wide");
10619        assert_eq!(attrs["width"], 80);
10620    }
10621
10622    #[test]
10623    fn inline_card_issue_553_reproducer() {
10624        // Verbatim reproducer from issue #553: an inlineCard in a paragraph
10625        // with preceding text must round-trip as an inlineCard, not degrade to
10626        // a text node with a link mark.
10627        let adf_json = r#"{
10628            "version": 1,
10629            "type": "doc",
10630            "content": [{
10631                "type": "paragraph",
10632                "content": [
10633                    {"type": "text", "text": "See the related page: "},
10634                    {"type": "inlineCard", "attrs": {
10635                        "url": "https://example.atlassian.net/wiki/spaces/ENG/pages/12345678"
10636                    }}
10637                ]
10638            }]
10639        }"#;
10640        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10641        let jfm = adf_to_markdown(&adf).unwrap();
10642        let rt = markdown_to_adf(&jfm).unwrap();
10643        let content = rt.content[0].content.as_ref().unwrap();
10644        assert_eq!(content.len(), 2);
10645        assert_eq!(content[0].node_type, "text");
10646        assert_eq!(content[1].node_type, "inlineCard");
10647        assert_eq!(
10648            content[1].attrs.as_ref().unwrap()["url"],
10649            "https://example.atlassian.net/wiki/spaces/ENG/pages/12345678"
10650        );
10651    }
10652
10653    #[test]
10654    fn inline_card_attr_form_parses_even_without_renderer() {
10655        // Directly parsing `:card[]{url="..."}` should yield an inlineCard.
10656        let doc = markdown_to_adf(r#":card[]{url="https://example.com/a"}"#).unwrap();
10657        let node = &doc.content[0].content.as_ref().unwrap()[0];
10658        assert_eq!(node.node_type, "inlineCard");
10659        assert_eq!(node.attrs.as_ref().unwrap()["url"], "https://example.com/a");
10660    }
10661
10662    #[test]
10663    fn inline_card_attr_form_url_overrides_content() {
10664        // When both bracket-content and `url=` attr are present, attr wins.
10665        // This keeps the parser forgiving of hand-edited JFM where a user
10666        // copied an old bracket form but added attrs.
10667        let doc =
10668            markdown_to_adf(r#":card[https://old.example.com]{url="https://new.example.com"}"#)
10669                .unwrap();
10670        let node = &doc.content[0].content.as_ref().unwrap()[0];
10671        assert_eq!(node.node_type, "inlineCard");
10672        assert_eq!(
10673            node.attrs.as_ref().unwrap()["url"],
10674            "https://new.example.com"
10675        );
10676    }
10677
10678    // ── Issue #553 (updated): mark-wrapped URL must not become inlineCard ──
10679
10680    #[test]
10681    fn url_with_link_and_underline_marks_round_trip() {
10682        // Issue #553 (updated reproducer): a `text` node whose content is a
10683        // URL and that carries both `link` and `underline` marks must round-
10684        // trip as text+marks, not be promoted to an `inlineCard`.
10685        let adf_json = r#"{
10686            "version": 1,
10687            "type": "doc",
10688            "content": [{
10689                "type": "paragraph",
10690                "content": [
10691                    {"type": "text", "text": "See results at: "},
10692                    {"type": "text",
10693                     "text": "https://example.com/projects/abc123/analytics",
10694                     "marks": [
10695                        {"type": "link", "attrs": {"href": "https://example.com/projects/abc123/analytics"}},
10696                        {"type": "underline"}
10697                     ]},
10698                    {"type": "text", "text": " for details."}
10699                ]
10700            }]
10701        }"#;
10702        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10703        let jfm = adf_to_markdown(&adf).unwrap();
10704        let rt = markdown_to_adf(&jfm).unwrap();
10705        let content = rt.content[0].content.as_ref().unwrap();
10706        assert_eq!(
10707            content.len(),
10708            3,
10709            "expected 3 inline nodes, got: {content:?}"
10710        );
10711        assert_eq!(content[0].node_type, "text");
10712        assert_eq!(
10713            content[1].node_type, "text",
10714            "must stay text, not inlineCard"
10715        );
10716        assert_eq!(
10717            content[1].text.as_deref(),
10718            Some("https://example.com/projects/abc123/analytics")
10719        );
10720        let mark_types: Vec<&str> = content[1]
10721            .marks
10722            .as_deref()
10723            .unwrap_or(&[])
10724            .iter()
10725            .map(|m| m.mark_type.as_str())
10726            .collect();
10727        assert_eq!(mark_types, vec!["link", "underline"], "marks lost");
10728        assert_eq!(content[2].node_type, "text");
10729    }
10730
10731    #[test]
10732    fn url_inside_bracketed_span_stays_text() {
10733        // `[URL]{underline}` in JFM means "underline this URL text", not
10734        // "create a smart link that's underlined".  The nested parse_inline
10735        // call must not auto-promote the bare URL to an inlineCard.
10736        let doc = markdown_to_adf("[https://example.com]{underline}").unwrap();
10737        let node = &doc.content[0].content.as_ref().unwrap()[0];
10738        assert_eq!(node.node_type, "text");
10739        assert_eq!(node.text.as_deref(), Some("https://example.com"));
10740        let mark_types: Vec<&str> = node
10741            .marks
10742            .as_deref()
10743            .unwrap_or(&[])
10744            .iter()
10745            .map(|m| m.mark_type.as_str())
10746            .collect();
10747        assert_eq!(mark_types, vec!["underline"]);
10748    }
10749
10750    #[test]
10751    fn url_inside_emphasis_stays_text() {
10752        // Bold, italic, and strike-wrapped URLs should remain as text nodes,
10753        // not get promoted to inlineCards by the nested inline parser.
10754        for (md, mark) in [
10755            ("**https://example.com**", "strong"),
10756            ("*https://example.com*", "em"),
10757            ("~~https://example.com~~", "strike"),
10758        ] {
10759            let doc = markdown_to_adf(md).unwrap();
10760            let node = &doc.content[0].content.as_ref().unwrap()[0];
10761            assert_eq!(node.node_type, "text", "md={md}: must be text");
10762            assert_eq!(node.text.as_deref(), Some("https://example.com"));
10763            let mark_types: Vec<&str> = node
10764                .marks
10765                .as_deref()
10766                .unwrap_or(&[])
10767                .iter()
10768                .map(|m| m.mark_type.as_str())
10769                .collect();
10770            assert_eq!(mark_types, vec![mark], "md={md}: wrong marks");
10771        }
10772    }
10773
10774    #[test]
10775    fn url_inside_span_directive_stays_text() {
10776        // `:span[URL]{color=red}` should not promote the URL to an inlineCard.
10777        let doc = markdown_to_adf(":span[https://example.com]{color=red}").unwrap();
10778        let node = &doc.content[0].content.as_ref().unwrap()[0];
10779        assert_eq!(node.node_type, "text");
10780        assert_eq!(node.text.as_deref(), Some("https://example.com"));
10781        let mark = &node.marks.as_ref().unwrap()[0];
10782        assert_eq!(mark.mark_type, "textColor");
10783    }
10784
10785    #[test]
10786    fn url_as_link_text_with_underline_after_link_mark_order() {
10787        // Reverse mark order — underline appears BEFORE link in the ADF array.
10788        // The JFM form is `[[text](url)]{underline}`; the nested parser must
10789        // still keep the URL as plain text.
10790        let adf_json = r#"{
10791            "version": 1,
10792            "type": "doc",
10793            "content": [{
10794                "type": "paragraph",
10795                "content": [
10796                    {"type": "text",
10797                     "text": "https://example.com",
10798                     "marks": [
10799                        {"type": "underline"},
10800                        {"type": "link", "attrs": {"href": "https://example.com"}}
10801                     ]}
10802                ]
10803            }]
10804        }"#;
10805        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10806        let jfm = adf_to_markdown(&adf).unwrap();
10807        let rt = markdown_to_adf(&jfm).unwrap();
10808        let node = &rt.content[0].content.as_ref().unwrap()[0];
10809        assert_eq!(node.node_type, "text", "must stay text, got: {node:?}");
10810        assert_eq!(node.text.as_deref(), Some("https://example.com"));
10811        let mark_types: Vec<&str> = node
10812            .marks
10813            .as_deref()
10814            .unwrap_or(&[])
10815            .iter()
10816            .map(|m| m.mark_type.as_str())
10817            .collect();
10818        assert_eq!(mark_types, vec!["underline", "link"]);
10819    }
10820
10821    #[test]
10822    fn bare_url_at_top_level_still_becomes_inline_card() {
10823        // Regression guard: the suppression only applies inside mark-wrapping
10824        // constructs.  A bare URL in ordinary paragraph text must still be
10825        // detected and promoted to an inlineCard.
10826        let doc = markdown_to_adf("Visit https://example.com today").unwrap();
10827        let content = doc.content[0].content.as_ref().unwrap();
10828        assert_eq!(content.len(), 3);
10829        assert_eq!(content[0].node_type, "text");
10830        assert_eq!(content[1].node_type, "inlineCard");
10831        assert_eq!(
10832            content[1].attrs.as_ref().unwrap()["url"],
10833            "https://example.com"
10834        );
10835        assert_eq!(content[2].node_type, "text");
10836    }
10837
10838    // ── Block-level attribute marks (Tier 5/6) ───────────────────────
10839
10840    #[test]
10841    fn paragraph_align_center() {
10842        let md = "Centered text.\n{align=center}";
10843        let doc = markdown_to_adf(md).unwrap();
10844        let marks = doc.content[0].marks.as_ref().unwrap();
10845        assert_eq!(marks[0].mark_type, "alignment");
10846        assert_eq!(marks[0].attrs.as_ref().unwrap()["align"], "center");
10847    }
10848
10849    #[test]
10850    fn adf_alignment_to_markdown() {
10851        let mut node = AdfNode::paragraph(vec![AdfNode::text("Centered.")]);
10852        node.marks = Some(vec![AdfMark::alignment("center")]);
10853        let doc = AdfDocument {
10854            version: 1,
10855            doc_type: "doc".to_string(),
10856            content: vec![node],
10857        };
10858        let md = adf_to_markdown(&doc).unwrap();
10859        assert!(md.contains("Centered."));
10860        assert!(md.contains("{align=center}"));
10861    }
10862
10863    #[test]
10864    fn round_trip_alignment() {
10865        let md = "Centered.\n{align=center}\n";
10866        let doc = markdown_to_adf(md).unwrap();
10867        let result = adf_to_markdown(&doc).unwrap();
10868        assert!(result.contains("{align=center}"));
10869    }
10870
10871    #[test]
10872    fn paragraph_indent() {
10873        let md = "Indented.\n{indent=2}";
10874        let doc = markdown_to_adf(md).unwrap();
10875        let marks = doc.content[0].marks.as_ref().unwrap();
10876        assert_eq!(marks[0].mark_type, "indentation");
10877        assert_eq!(marks[0].attrs.as_ref().unwrap()["level"], 2);
10878    }
10879
10880    #[test]
10881    fn code_block_breakout() {
10882        let md = "```python\ndef f(): pass\n```\n{breakout=wide}";
10883        let doc = markdown_to_adf(md).unwrap();
10884        let marks = doc.content[0].marks.as_ref().unwrap();
10885        assert_eq!(marks[0].mark_type, "breakout");
10886        assert_eq!(marks[0].attrs.as_ref().unwrap()["mode"], "wide");
10887        assert!(marks[0].attrs.as_ref().unwrap().get("width").is_none());
10888    }
10889
10890    #[test]
10891    fn code_block_breakout_with_width() {
10892        let md = "```python\ndef f(): pass\n```\n{breakout=wide breakoutWidth=1200}";
10893        let doc = markdown_to_adf(md).unwrap();
10894        let marks = doc.content[0].marks.as_ref().unwrap();
10895        assert_eq!(marks[0].mark_type, "breakout");
10896        assert_eq!(marks[0].attrs.as_ref().unwrap()["mode"], "wide");
10897        assert_eq!(marks[0].attrs.as_ref().unwrap()["width"], 1200);
10898    }
10899
10900    #[test]
10901    fn adf_breakout_to_markdown() {
10902        let mut node = AdfNode::code_block(Some("python"), "pass");
10903        node.marks = Some(vec![AdfMark::breakout("wide", None)]);
10904        let doc = AdfDocument {
10905            version: 1,
10906            doc_type: "doc".to_string(),
10907            content: vec![node],
10908        };
10909        let md = adf_to_markdown(&doc).unwrap();
10910        assert!(md.contains("{breakout=wide}"));
10911        assert!(!md.contains("breakoutWidth"));
10912    }
10913
10914    #[test]
10915    fn adf_breakout_with_width_to_markdown() {
10916        let mut node = AdfNode::code_block(Some("python"), "pass");
10917        node.marks = Some(vec![AdfMark::breakout("wide", Some(1200))]);
10918        let doc = AdfDocument {
10919            version: 1,
10920            doc_type: "doc".to_string(),
10921            content: vec![node],
10922        };
10923        let md = adf_to_markdown(&doc).unwrap();
10924        assert!(md.contains("breakout=wide"));
10925        assert!(md.contains("breakoutWidth=1200"));
10926    }
10927
10928    #[test]
10929    fn breakout_width_round_trip() {
10930        let adf_json = r#"{"version":1,"type":"doc","content":[{
10931            "type":"codeBlock",
10932            "attrs":{"language":"text"},
10933            "marks":[{"type":"breakout","attrs":{"mode":"wide","width":1200}}],
10934            "content":[{"type":"text","text":"some code"}]
10935        }]}"#;
10936        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10937        let md = adf_to_markdown(&doc).unwrap();
10938        assert!(md.contains("breakout=wide"));
10939        assert!(md.contains("breakoutWidth=1200"));
10940        let round_tripped = markdown_to_adf(&md).unwrap();
10941        let marks = round_tripped.content[0].marks.as_ref().unwrap();
10942        let breakout = marks.iter().find(|m| m.mark_type == "breakout").unwrap();
10943        assert_eq!(breakout.attrs.as_ref().unwrap()["mode"], "wide");
10944        assert_eq!(breakout.attrs.as_ref().unwrap()["width"], 1200);
10945    }
10946
10947    // ── Attribute extensions — media & table (Tier 5) ────────────────
10948
10949    #[test]
10950    fn image_with_layout_attrs() {
10951        let doc = markdown_to_adf("![alt](url){layout=wide width=80}").unwrap();
10952        let node = &doc.content[0];
10953        assert_eq!(node.node_type, "mediaSingle");
10954        let attrs = node.attrs.as_ref().unwrap();
10955        assert_eq!(attrs["layout"], "wide");
10956        assert_eq!(attrs["width"], 80);
10957    }
10958
10959    #[test]
10960    fn adf_image_with_layout_to_markdown() {
10961        let mut node = AdfNode::media_single("url", Some("alt"));
10962        node.attrs.as_mut().unwrap()["layout"] = serde_json::json!("wide");
10963        node.attrs.as_mut().unwrap()["width"] = serde_json::json!(80);
10964        let doc = AdfDocument {
10965            version: 1,
10966            doc_type: "doc".to_string(),
10967            content: vec![node],
10968        };
10969        let md = adf_to_markdown(&doc).unwrap();
10970        assert!(md.contains("![alt](url){layout=wide width=80}"));
10971    }
10972
10973    #[test]
10974    fn table_with_layout_attrs() {
10975        let md = "| H |\n| --- |\n| C |\n{layout=wide numbered}";
10976        let doc = markdown_to_adf(md).unwrap();
10977        let table = &doc.content[0];
10978        assert_eq!(table.node_type, "table");
10979        let attrs = table.attrs.as_ref().unwrap();
10980        assert_eq!(attrs["layout"], "wide");
10981        assert_eq!(attrs["isNumberColumnEnabled"], true);
10982    }
10983
10984    #[test]
10985    fn adf_table_with_attrs_to_markdown() {
10986        let mut table = AdfNode::table(vec![
10987            AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
10988                AdfNode::text("H"),
10989            ])])]),
10990            AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
10991                AdfNode::text("C"),
10992            ])])]),
10993        ]);
10994        table.attrs = Some(serde_json::json!({"layout": "wide", "isNumberColumnEnabled": true}));
10995        let doc = AdfDocument {
10996            version: 1,
10997            doc_type: "doc".to_string(),
10998            content: vec![table],
10999        };
11000        let md = adf_to_markdown(&doc).unwrap();
11001        assert!(md.contains("{layout=wide numbered}"));
11002    }
11003
11004    // ── Attribute extensions — inline marks (Tier 5) ─────────────────
11005
11006    #[test]
11007    fn underline_bracketed_span() {
11008        let doc = markdown_to_adf("This is [underlined text]{underline} here.").unwrap();
11009        let content = doc.content[0].content.as_ref().unwrap();
11010        assert_eq!(content[1].text.as_deref(), Some("underlined text"));
11011        let marks = content[1].marks.as_ref().unwrap();
11012        assert_eq!(marks[0].mark_type, "underline");
11013    }
11014
11015    #[test]
11016    fn adf_underline_to_markdown() {
11017        let doc = AdfDocument {
11018            version: 1,
11019            doc_type: "doc".to_string(),
11020            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11021                "underlined",
11022                vec![AdfMark::underline()],
11023            )])],
11024        };
11025        let md = adf_to_markdown(&doc).unwrap();
11026        assert!(md.contains("[underlined]{underline}"));
11027    }
11028
11029    #[test]
11030    fn round_trip_underline() {
11031        let md = "This is [underlined text]{underline} here.\n";
11032        let doc = markdown_to_adf(md).unwrap();
11033        let result = adf_to_markdown(&doc).unwrap();
11034        assert!(result.contains("[underlined text]{underline}"));
11035    }
11036
11037    #[test]
11038    fn mark_ordering_underline_strong_preserved() {
11039        // Issue #383: mark ordering was non-deterministic
11040        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11041          {"type":"text","text":"bold and underlined","marks":[{"type":"underline"},{"type":"strong"}]}
11042        ]}]}"#;
11043        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11044        let md = adf_to_markdown(&doc).unwrap();
11045        let round_tripped = markdown_to_adf(&md).unwrap();
11046        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11047        let mark_types: Vec<&str> = node
11048            .marks
11049            .as_ref()
11050            .unwrap()
11051            .iter()
11052            .map(|m| m.mark_type.as_str())
11053            .collect();
11054        assert_eq!(
11055            mark_types,
11056            vec!["underline", "strong"],
11057            "mark order should be preserved, got: {mark_types:?}"
11058        );
11059    }
11060
11061    #[test]
11062    fn mark_ordering_link_strong_preserved() {
11063        // Issue #403: link+strong mark order was swapped on round-trip
11064        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11065          {"type":"text","text":"bold link","marks":[
11066            {"type":"link","attrs":{"href":"https://example.com"}},
11067            {"type":"strong"}
11068          ]}
11069        ]}]}"#;
11070        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11071        let md = adf_to_markdown(&doc).unwrap();
11072        let round_tripped = markdown_to_adf(&md).unwrap();
11073        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11074        let mark_types: Vec<&str> = node
11075            .marks
11076            .as_ref()
11077            .unwrap()
11078            .iter()
11079            .map(|m| m.mark_type.as_str())
11080            .collect();
11081        assert_eq!(
11082            mark_types,
11083            vec!["link", "strong"],
11084            "mark order should be preserved, got: {mark_types:?}"
11085        );
11086    }
11087
11088    #[test]
11089    fn mark_ordering_link_textcolor_preserved() {
11090        // Issue #403 comment: link+textColor mark order was swapped on round-trip
11091        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11092          {"type":"text","text":"red link","marks":[
11093            {"type":"link","attrs":{"href":"https://example.com"}},
11094            {"type":"textColor","attrs":{"color":"#ff0000"}}
11095          ]}
11096        ]}]}"##;
11097        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11098        let md = adf_to_markdown(&doc).unwrap();
11099        let round_tripped = markdown_to_adf(&md).unwrap();
11100        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11101        let mark_types: Vec<&str> = node
11102            .marks
11103            .as_ref()
11104            .unwrap()
11105            .iter()
11106            .map(|m| m.mark_type.as_str())
11107            .collect();
11108        assert_eq!(
11109            mark_types,
11110            vec!["link", "textColor"],
11111            "mark order should be preserved, got: {mark_types:?}"
11112        );
11113    }
11114
11115    #[test]
11116    fn mark_ordering_link_em_preserved() {
11117        // Issue #403: link+em mark order should be preserved
11118        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11119          {"type":"text","text":"italic link","marks":[
11120            {"type":"link","attrs":{"href":"https://example.com"}},
11121            {"type":"em"}
11122          ]}
11123        ]}]}"#;
11124        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11125        let md = adf_to_markdown(&doc).unwrap();
11126        let round_tripped = markdown_to_adf(&md).unwrap();
11127        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11128        let mark_types: Vec<&str> = node
11129            .marks
11130            .as_ref()
11131            .unwrap()
11132            .iter()
11133            .map(|m| m.mark_type.as_str())
11134            .collect();
11135        assert_eq!(
11136            mark_types,
11137            vec!["link", "em"],
11138            "mark order should be preserved, got: {mark_types:?}"
11139        );
11140    }
11141
11142    #[test]
11143    fn mark_ordering_link_strike_preserved() {
11144        // Issue #403: link+strike mark order should be preserved
11145        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11146          {"type":"text","text":"struck link","marks":[
11147            {"type":"link","attrs":{"href":"https://example.com"}},
11148            {"type":"strike"}
11149          ]}
11150        ]}]}"#;
11151        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11152        let md = adf_to_markdown(&doc).unwrap();
11153        let round_tripped = markdown_to_adf(&md).unwrap();
11154        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11155        let mark_types: Vec<&str> = node
11156            .marks
11157            .as_ref()
11158            .unwrap()
11159            .iter()
11160            .map(|m| m.mark_type.as_str())
11161            .collect();
11162        assert_eq!(
11163            mark_types,
11164            vec!["link", "strike"],
11165            "mark order should be preserved, got: {mark_types:?}"
11166        );
11167    }
11168
11169    #[test]
11170    fn mark_ordering_strong_link_preserved() {
11171        // Issue #403: [strong, link] order must also be preserved (reverse direction)
11172        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11173          {"type":"text","text":"bold link","marks":[
11174            {"type":"strong"},
11175            {"type":"link","attrs":{"href":"https://example.com"}}
11176          ]}
11177        ]}]}"#;
11178        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11179        let md = adf_to_markdown(&doc).unwrap();
11180        let round_tripped = markdown_to_adf(&md).unwrap();
11181        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11182        let mark_types: Vec<&str> = node
11183            .marks
11184            .as_ref()
11185            .unwrap()
11186            .iter()
11187            .map(|m| m.mark_type.as_str())
11188            .collect();
11189        assert_eq!(
11190            mark_types,
11191            vec!["strong", "link"],
11192            "mark order should be preserved, got: {mark_types:?}"
11193        );
11194    }
11195
11196    #[test]
11197    fn mark_ordering_em_link_preserved() {
11198        // Issue #403: [em, link] order must also be preserved
11199        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11200          {"type":"text","text":"italic link","marks":[
11201            {"type":"em"},
11202            {"type":"link","attrs":{"href":"https://example.com"}}
11203          ]}
11204        ]}]}"#;
11205        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11206        let md = adf_to_markdown(&doc).unwrap();
11207        let round_tripped = markdown_to_adf(&md).unwrap();
11208        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11209        let mark_types: Vec<&str> = node
11210            .marks
11211            .as_ref()
11212            .unwrap()
11213            .iter()
11214            .map(|m| m.mark_type.as_str())
11215            .collect();
11216        assert_eq!(
11217            mark_types,
11218            vec!["em", "link"],
11219            "mark order should be preserved, got: {mark_types:?}"
11220        );
11221    }
11222
11223    #[test]
11224    fn mark_ordering_strike_link_preserved() {
11225        // Issue #403: [strike, link] order must also be preserved
11226        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11227          {"type":"text","text":"struck link","marks":[
11228            {"type":"strike"},
11229            {"type":"link","attrs":{"href":"https://example.com"}}
11230          ]}
11231        ]}]}"#;
11232        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11233        let md = adf_to_markdown(&doc).unwrap();
11234        let round_tripped = markdown_to_adf(&md).unwrap();
11235        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11236        let mark_types: Vec<&str> = node
11237            .marks
11238            .as_ref()
11239            .unwrap()
11240            .iter()
11241            .map(|m| m.mark_type.as_str())
11242            .collect();
11243        assert_eq!(
11244            mark_types,
11245            vec!["strike", "link"],
11246            "mark order should be preserved, got: {mark_types:?}"
11247        );
11248    }
11249
11250    #[test]
11251    fn mark_ordering_underline_link_preserved() {
11252        // Issue #403: [underline, link] order must be preserved
11253        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11254          {"type":"text","text":"click here","marks":[
11255            {"type":"underline"},
11256            {"type":"link","attrs":{"href":"https://example.com"}}
11257          ]}
11258        ]}]}"#;
11259        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11260        let md = adf_to_markdown(&doc).unwrap();
11261        let round_tripped = markdown_to_adf(&md).unwrap();
11262        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11263        let mark_types: Vec<&str> = node
11264            .marks
11265            .as_ref()
11266            .unwrap()
11267            .iter()
11268            .map(|m| m.mark_type.as_str())
11269            .collect();
11270        assert_eq!(
11271            mark_types,
11272            vec!["underline", "link"],
11273            "mark order should be preserved, got: {mark_types:?}"
11274        );
11275    }
11276
11277    #[test]
11278    fn mark_ordering_textcolor_link_preserved() {
11279        // Issue #403: [textColor, link] order must be preserved
11280        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11281          {"type":"text","text":"red link","marks":[
11282            {"type":"textColor","attrs":{"color":"#ff0000"}},
11283            {"type":"link","attrs":{"href":"https://example.com"}}
11284          ]}
11285        ]}]}"##;
11286        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11287        let md = adf_to_markdown(&doc).unwrap();
11288        let round_tripped = markdown_to_adf(&md).unwrap();
11289        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11290        let mark_types: Vec<&str> = node
11291            .marks
11292            .as_ref()
11293            .unwrap()
11294            .iter()
11295            .map(|m| m.mark_type.as_str())
11296            .collect();
11297        assert_eq!(
11298            mark_types,
11299            vec!["textColor", "link"],
11300            "mark order should be preserved, got: {mark_types:?}"
11301        );
11302    }
11303
11304    #[test]
11305    fn mark_ordering_link_underline_preserved() {
11306        // Issue #403: [link, underline] order must be preserved (link wraps bracketed span)
11307        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11308          {"type":"text","text":"click here","marks":[
11309            {"type":"link","attrs":{"href":"https://example.com"}},
11310            {"type":"underline"}
11311          ]}
11312        ]}]}"#;
11313        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11314        let md = adf_to_markdown(&doc).unwrap();
11315        // Link should wrap the underline bracketed span: [[click here]{underline}](url)
11316        assert!(
11317            md.contains("](https://example.com)"),
11318            "should have link: {md}"
11319        );
11320        assert!(md.contains("underline"), "should have underline: {md}");
11321        let round_tripped = markdown_to_adf(&md).unwrap();
11322        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11323        let mark_types: Vec<&str> = node
11324            .marks
11325            .as_ref()
11326            .unwrap()
11327            .iter()
11328            .map(|m| m.mark_type.as_str())
11329            .collect();
11330        assert_eq!(
11331            mark_types,
11332            vec!["link", "underline"],
11333            "mark order should be preserved, got: {mark_types:?}"
11334        );
11335    }
11336
11337    #[test]
11338    fn mark_ordering_underline_strong_link_preserved() {
11339        // Issue #491: [underline, strong, link] reordered to [strong, underline, link]
11340        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11341          {"type":"text","text":"bold underlined link","marks":[
11342            {"type":"underline"},
11343            {"type":"strong"},
11344            {"type":"link","attrs":{"href":"https://example.com/page"}}
11345          ]}
11346        ]}]}"#;
11347        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11348        let md = adf_to_markdown(&doc).unwrap();
11349        let round_tripped = markdown_to_adf(&md).unwrap();
11350        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11351        let mark_types: Vec<&str> = node
11352            .marks
11353            .as_ref()
11354            .unwrap()
11355            .iter()
11356            .map(|m| m.mark_type.as_str())
11357            .collect();
11358        assert_eq!(
11359            mark_types,
11360            vec!["underline", "strong", "link"],
11361            "mark order should be preserved, got: {mark_types:?}"
11362        );
11363    }
11364
11365    #[test]
11366    fn mark_ordering_strong_underline_link_preserved() {
11367        // Issue #491: verify [strong, underline, link] is preserved
11368        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11369          {"type":"text","text":"bold underlined link","marks":[
11370            {"type":"strong"},
11371            {"type":"underline"},
11372            {"type":"link","attrs":{"href":"https://example.com/page"}}
11373          ]}
11374        ]}]}"#;
11375        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11376        let md = adf_to_markdown(&doc).unwrap();
11377        let round_tripped = markdown_to_adf(&md).unwrap();
11378        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11379        let mark_types: Vec<&str> = node
11380            .marks
11381            .as_ref()
11382            .unwrap()
11383            .iter()
11384            .map(|m| m.mark_type.as_str())
11385            .collect();
11386        assert_eq!(
11387            mark_types,
11388            vec!["strong", "underline", "link"],
11389            "mark order should be preserved, got: {mark_types:?}"
11390        );
11391    }
11392
11393    #[test]
11394    fn mark_ordering_underline_em_link_preserved() {
11395        // Issue #491: verify [underline, em, link] is preserved
11396        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11397          {"type":"text","text":"italic underlined link","marks":[
11398            {"type":"underline"},
11399            {"type":"em"},
11400            {"type":"link","attrs":{"href":"https://example.com/page"}}
11401          ]}
11402        ]}]}"#;
11403        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11404        let md = adf_to_markdown(&doc).unwrap();
11405        let round_tripped = markdown_to_adf(&md).unwrap();
11406        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11407        let mark_types: Vec<&str> = node
11408            .marks
11409            .as_ref()
11410            .unwrap()
11411            .iter()
11412            .map(|m| m.mark_type.as_str())
11413            .collect();
11414        assert_eq!(
11415            mark_types,
11416            vec!["underline", "em", "link"],
11417            "mark order should be preserved, got: {mark_types:?}"
11418        );
11419    }
11420
11421    #[test]
11422    fn mark_ordering_underline_strike_link_preserved() {
11423        // Issue #491: verify [underline, strike, link] is preserved
11424        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11425          {"type":"text","text":"struck underlined link","marks":[
11426            {"type":"underline"},
11427            {"type":"strike"},
11428            {"type":"link","attrs":{"href":"https://example.com/page"}}
11429          ]}
11430        ]}]}"#;
11431        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11432        let md = adf_to_markdown(&doc).unwrap();
11433        let round_tripped = markdown_to_adf(&md).unwrap();
11434        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11435        let mark_types: Vec<&str> = node
11436            .marks
11437            .as_ref()
11438            .unwrap()
11439            .iter()
11440            .map(|m| m.mark_type.as_str())
11441            .collect();
11442        assert_eq!(
11443            mark_types,
11444            vec!["underline", "strike", "link"],
11445            "mark order should be preserved, got: {mark_types:?}"
11446        );
11447    }
11448
11449    #[test]
11450    fn mark_ordering_underline_strong_em_link_preserved() {
11451        // Issue #491: verify four-mark combo [underline, strong, em, link] is preserved
11452        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11453          {"type":"text","text":"all the marks","marks":[
11454            {"type":"underline"},
11455            {"type":"strong"},
11456            {"type":"em"},
11457            {"type":"link","attrs":{"href":"https://example.com/page"}}
11458          ]}
11459        ]}]}"#;
11460        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11461        let md = adf_to_markdown(&doc).unwrap();
11462        let round_tripped = markdown_to_adf(&md).unwrap();
11463        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11464        let mark_types: Vec<&str> = node
11465            .marks
11466            .as_ref()
11467            .unwrap()
11468            .iter()
11469            .map(|m| m.mark_type.as_str())
11470            .collect();
11471        assert_eq!(
11472            mark_types,
11473            vec!["underline", "strong", "em", "link"],
11474            "mark order should be preserved, got: {mark_types:?}"
11475        );
11476    }
11477
11478    #[test]
11479    fn em_strong_round_trip() {
11480        // Issue #401: em mark dropped when combined with strong
11481        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11482          {"type":"text","text":"bold and italic","marks":[{"type":"strong"},{"type":"em"}]}
11483        ]}]}"#;
11484        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11485        let md = adf_to_markdown(&doc).unwrap();
11486        assert_eq!(md.trim(), "***bold and italic***");
11487        let round_tripped = markdown_to_adf(&md).unwrap();
11488        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11489        assert_eq!(node.text.as_deref(), Some("bold and italic"));
11490        let mark_types: Vec<&str> = node
11491            .marks
11492            .as_ref()
11493            .unwrap()
11494            .iter()
11495            .map(|m| m.mark_type.as_str())
11496            .collect();
11497        assert_eq!(
11498            mark_types,
11499            vec!["strong", "em"],
11500            "both strong and em marks should be preserved, got: {mark_types:?}"
11501        );
11502    }
11503
11504    #[test]
11505    fn em_strong_round_trip_em_first() {
11506        // Issue #549: [em, strong] mark order must be preserved on round-trip.
11507        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11508          {"type":"text","text":"italic and bold","marks":[{"type":"em"},{"type":"strong"}]}
11509        ]}]}"#;
11510        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11511        let md = adf_to_markdown(&doc).unwrap();
11512        let round_tripped = markdown_to_adf(&md).unwrap();
11513        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11514        assert_eq!(node.text.as_deref(), Some("italic and bold"));
11515        let mark_types: Vec<&str> = node
11516            .marks
11517            .as_ref()
11518            .unwrap()
11519            .iter()
11520            .map(|m| m.mark_type.as_str())
11521            .collect();
11522        assert_eq!(
11523            mark_types,
11524            vec!["em", "strong"],
11525            "mark order [em, strong] should be preserved, got: {mark_types:?}"
11526        );
11527    }
11528
11529    /// Round-trips an inline text node with the given marks through ADF → JFM → ADF
11530    /// and asserts the resulting mark types match `expected`.
11531    fn assert_mark_order_round_trip(marks: Vec<AdfMark>, expected: &[&str]) {
11532        let doc = AdfDocument {
11533            version: 1,
11534            doc_type: "doc".to_string(),
11535            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11536                "text", marks,
11537            )])],
11538        };
11539        let md = adf_to_markdown(&doc).unwrap();
11540        let round_tripped = markdown_to_adf(&md).unwrap();
11541        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11542        let mark_types: Vec<&str> = node
11543            .marks
11544            .as_ref()
11545            .expect("round-tripped node should have marks")
11546            .iter()
11547            .map(|m| m.mark_type.as_str())
11548            .collect();
11549        assert_eq!(
11550            mark_types, expected,
11551            "marks should round-trip in order via {md:?}"
11552        );
11553    }
11554
11555    #[test]
11556    fn round_trip_em_strong_mark_order() {
11557        // Issue #549: em + strong in either order must round-trip.
11558        assert_mark_order_round_trip(vec![AdfMark::em(), AdfMark::strong()], &["em", "strong"]);
11559        assert_mark_order_round_trip(vec![AdfMark::strong(), AdfMark::em()], &["strong", "em"]);
11560    }
11561
11562    #[test]
11563    fn round_trip_strong_underline_mark_order() {
11564        // Issue #549: strong + underline in either order must round-trip.
11565        assert_mark_order_round_trip(
11566            vec![AdfMark::strong(), AdfMark::underline()],
11567            &["strong", "underline"],
11568        );
11569        assert_mark_order_round_trip(
11570            vec![AdfMark::underline(), AdfMark::strong()],
11571            &["underline", "strong"],
11572        );
11573    }
11574
11575    #[test]
11576    fn round_trip_em_underline_mark_order() {
11577        assert_mark_order_round_trip(
11578            vec![AdfMark::em(), AdfMark::underline()],
11579            &["em", "underline"],
11580        );
11581        assert_mark_order_round_trip(
11582            vec![AdfMark::underline(), AdfMark::em()],
11583            &["underline", "em"],
11584        );
11585    }
11586
11587    #[test]
11588    fn round_trip_strike_strong_em_permutations() {
11589        // Each permutation of {strike, strong, em} must round-trip the mark order
11590        // exactly, because the Atlassian ADF spec does not define a canonical mark
11591        // ordering and we preserve whatever ordering Jira delivered.
11592        assert_mark_order_round_trip(
11593            vec![AdfMark::strike(), AdfMark::strong(), AdfMark::em()],
11594            &["strike", "strong", "em"],
11595        );
11596        assert_mark_order_round_trip(
11597            vec![AdfMark::strike(), AdfMark::em(), AdfMark::strong()],
11598            &["strike", "em", "strong"],
11599        );
11600        assert_mark_order_round_trip(
11601            vec![AdfMark::strong(), AdfMark::strike(), AdfMark::em()],
11602            &["strong", "strike", "em"],
11603        );
11604        assert_mark_order_round_trip(
11605            vec![AdfMark::strong(), AdfMark::em(), AdfMark::strike()],
11606            &["strong", "em", "strike"],
11607        );
11608        assert_mark_order_round_trip(
11609            vec![AdfMark::em(), AdfMark::strike(), AdfMark::strong()],
11610            &["em", "strike", "strong"],
11611        );
11612        assert_mark_order_round_trip(
11613            vec![AdfMark::em(), AdfMark::strong(), AdfMark::strike()],
11614            &["em", "strong", "strike"],
11615        );
11616    }
11617
11618    #[test]
11619    fn round_trip_underline_nested_with_strong_em() {
11620        // Underline may sit outside, between, or inside strong/em — each position
11621        // must round-trip.
11622        assert_mark_order_round_trip(
11623            vec![AdfMark::underline(), AdfMark::strong(), AdfMark::em()],
11624            &["underline", "strong", "em"],
11625        );
11626        assert_mark_order_round_trip(
11627            vec![AdfMark::strong(), AdfMark::underline(), AdfMark::em()],
11628            &["strong", "underline", "em"],
11629        );
11630        assert_mark_order_round_trip(
11631            vec![AdfMark::strong(), AdfMark::em(), AdfMark::underline()],
11632            &["strong", "em", "underline"],
11633        );
11634    }
11635
11636    #[test]
11637    fn round_trip_span_attr_order_preserved() {
11638        // Issue #549: the `:span` directive always parses color/bg/subsup
11639        // attrs in a fixed order, so non-canonical orderings must be emitted
11640        // as nested :span wrappers rather than a single merged wrapper.
11641        assert_mark_order_round_trip(
11642            vec![
11643                AdfMark::background_color("#ffff00"),
11644                AdfMark::text_color("#ff0000"),
11645            ],
11646            &["backgroundColor", "textColor"],
11647        );
11648        assert_mark_order_round_trip(
11649            vec![AdfMark::subsup("sub"), AdfMark::text_color("#ff0000")],
11650            &["subsup", "textColor"],
11651        );
11652        assert_mark_order_round_trip(
11653            vec![
11654                AdfMark::text_color("#ff0000"),
11655                AdfMark::background_color("#ffff00"),
11656            ],
11657            &["textColor", "backgroundColor"],
11658        );
11659    }
11660
11661    #[test]
11662    fn round_trip_annotation_before_underline() {
11663        // Issue #549: the bracketed-span parser reads `underline` before any
11664        // annotation-ids, so `[annotation, underline]` must be emitted as
11665        // nested wrappers rather than one merged `[text]{underline annotation-id=X}`.
11666        assert_mark_order_round_trip(
11667            vec![
11668                AdfMark::annotation("ann-1", "inlineComment"),
11669                AdfMark::underline(),
11670            ],
11671            &["annotation", "underline"],
11672        );
11673        assert_mark_order_round_trip(
11674            vec![
11675                AdfMark::annotation("ann-1", "inlineComment"),
11676                AdfMark::underline(),
11677                AdfMark::annotation("ann-2", "inlineComment"),
11678            ],
11679            &["annotation", "underline", "annotation"],
11680        );
11681    }
11682
11683    #[test]
11684    fn round_trip_em_content_with_underscores() {
11685        // When em renders as `_..._` (to disambiguate from strong), any literal
11686        // underscores in the text must be escaped so they don't close the
11687        // emphasis span early.  Text like "foo_bar_baz" with [em, strong] must
11688        // survive round-trip with the underscores intact.
11689        let doc = AdfDocument {
11690            version: 1,
11691            doc_type: "doc".to_string(),
11692            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11693                "foo _bar_ baz",
11694                vec![AdfMark::em(), AdfMark::strong()],
11695            )])],
11696        };
11697        let md = adf_to_markdown(&doc).unwrap();
11698        let round_tripped = markdown_to_adf(&md).unwrap();
11699        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11700        assert_eq!(node.text.as_deref(), Some("foo _bar_ baz"));
11701        let mark_types: Vec<&str> = node
11702            .marks
11703            .as_ref()
11704            .unwrap()
11705            .iter()
11706            .map(|m| m.mark_type.as_str())
11707            .collect();
11708        assert_eq!(mark_types, vec!["em", "strong"]);
11709    }
11710
11711    #[test]
11712    fn round_trip_link_nested_with_formatting_marks() {
11713        // Link may sit at any position in the marks array relative to em,
11714        // strong, strike, and underline — each position must round-trip.
11715        assert_mark_order_round_trip(
11716            vec![
11717                AdfMark::link("https://example.com"),
11718                AdfMark::strong(),
11719                AdfMark::em(),
11720            ],
11721            &["link", "strong", "em"],
11722        );
11723        assert_mark_order_round_trip(
11724            vec![
11725                AdfMark::em(),
11726                AdfMark::strong(),
11727                AdfMark::link("https://example.com"),
11728            ],
11729            &["em", "strong", "link"],
11730        );
11731        assert_mark_order_round_trip(
11732            vec![
11733                AdfMark::underline(),
11734                AdfMark::link("https://example.com"),
11735                AdfMark::strong(),
11736            ],
11737            &["underline", "link", "strong"],
11738        );
11739    }
11740
11741    /// Builds an `AdfMark` with the given type and no attrs, bypassing the
11742    /// usual constructors so we can exercise the defensive branches in the
11743    /// render helpers (the constructors always populate `attrs`).
11744    fn bare_mark(mark_type: &str) -> AdfMark {
11745        AdfMark {
11746            mark_type: mark_type.to_string(),
11747            attrs: None,
11748        }
11749    }
11750
11751    #[test]
11752    fn collect_span_attr_handles_missing_attrs() {
11753        // `textColor`/`backgroundColor`/`subsup` marks without the expected
11754        // `color`/`type` attr must not emit a fragment (the `if let` falls
11755        // through without pushing).  This exercises the inner-None branches
11756        // that the typed-constructor tests otherwise skip.
11757        let mut attrs = Vec::new();
11758        collect_span_attr(&bare_mark("textColor"), &mut attrs);
11759        collect_span_attr(&bare_mark("backgroundColor"), &mut attrs);
11760        collect_span_attr(&bare_mark("subsup"), &mut attrs);
11761        collect_span_attr(&bare_mark("link"), &mut attrs);
11762        assert!(attrs.is_empty(), "got: {attrs:?}");
11763    }
11764
11765    #[test]
11766    fn collect_bracketed_attr_handles_missing_attrs() {
11767        // An annotation mark with no attrs map at all must silently produce
11768        // no fragments — this covers the outer `if let Some(ref a)` None arm.
11769        let mut attrs = Vec::new();
11770        collect_bracketed_attr(&bare_mark("annotation"), &mut attrs);
11771        collect_bracketed_attr(&bare_mark("strong"), &mut attrs);
11772        assert!(attrs.is_empty(), "got: {attrs:?}");
11773    }
11774
11775    #[test]
11776    fn collect_bracketed_attr_handles_annotation_without_id() {
11777        // An annotation mark with attrs present but missing `id` and
11778        // `annotationType` keys still emits nothing — exercises the inner
11779        // None branches of each `if let` in the annotation arm.
11780        let mark = AdfMark {
11781            mark_type: "annotation".to_string(),
11782            attrs: Some(serde_json::json!({})),
11783        };
11784        let mut attrs = Vec::new();
11785        collect_bracketed_attr(&mark, &mut attrs);
11786        assert!(attrs.is_empty(), "got: {attrs:?}");
11787    }
11788
11789    #[test]
11790    fn span_attr_order_rejects_unknown_types() {
11791        // `span_attr_order` must classify unknown mark types as the sentinel
11792        // value, and `span_run_is_canonical` must reject a run that contains
11793        // any such unknown type.
11794        assert_eq!(span_attr_order("textColor"), 0);
11795        assert_eq!(span_attr_order("backgroundColor"), 1);
11796        assert_eq!(span_attr_order("subsup"), 2);
11797        assert_eq!(span_attr_order("strong"), u8::MAX);
11798        assert!(!span_run_is_canonical(&[bare_mark("strong")]));
11799    }
11800
11801    #[test]
11802    fn bracketed_run_rejects_unknown_types() {
11803        // `bracketed_run_is_canonical` only accepts `underline` and
11804        // `annotation`; any other mark type in the run short-circuits to
11805        // `false` so the caller emits nested wrappers.
11806        assert!(bracketed_run_is_canonical(&[
11807            AdfMark::underline(),
11808            AdfMark::annotation("x", "inlineComment")
11809        ]));
11810        assert!(!bracketed_run_is_canonical(&[
11811            AdfMark::annotation("x", "inlineComment"),
11812            AdfMark::underline()
11813        ]));
11814        assert!(!bracketed_run_is_canonical(&[bare_mark("strong")]));
11815    }
11816
11817    #[test]
11818    fn render_marked_text_ignores_unknown_mark_types() {
11819        // Unknown mark types fall through `render_marked_text`'s `_ =>`
11820        // arm and are dropped; the rendered JFM must still produce the
11821        // underlying text (and round-trip back to an unmarked text node).
11822        let doc = AdfDocument {
11823            version: 1,
11824            doc_type: "doc".to_string(),
11825            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11826                "hello",
11827                vec![bare_mark("futureMark"), AdfMark::strong()],
11828            )])],
11829        };
11830        let md = adf_to_markdown(&doc).unwrap();
11831        assert_eq!(md.trim(), "**hello**");
11832        let rt = markdown_to_adf(&md).unwrap();
11833        let node = &rt.content[0].content.as_ref().unwrap()[0];
11834        assert_eq!(node.text.as_deref(), Some("hello"));
11835        let mark_types: Vec<&str> = node
11836            .marks
11837            .as_ref()
11838            .unwrap()
11839            .iter()
11840            .map(|m| m.mark_type.as_str())
11841            .collect();
11842        assert_eq!(mark_types, vec!["strong"]);
11843    }
11844
11845    #[test]
11846    fn triple_asterisk_parse_to_adf() {
11847        // Issue #401: ***text*** should parse as text with strong+em marks
11848        let md = "***bold and italic***\n";
11849        let doc = markdown_to_adf(md).unwrap();
11850        let node = &doc.content[0].content.as_ref().unwrap()[0];
11851        assert_eq!(node.text.as_deref(), Some("bold and italic"));
11852        let mark_types: Vec<&str> = node
11853            .marks
11854            .as_ref()
11855            .unwrap()
11856            .iter()
11857            .map(|m| m.mark_type.as_str())
11858            .collect();
11859        assert!(
11860            mark_types.contains(&"strong") && mark_types.contains(&"em"),
11861            "***text*** should produce both strong and em marks, got: {mark_types:?}"
11862        );
11863    }
11864
11865    #[test]
11866    fn triple_asterisk_with_surrounding_text() {
11867        // Issue #401: surrounding text should not be affected
11868        let md = "before ***bold italic*** after\n";
11869        let doc = markdown_to_adf(md).unwrap();
11870        let nodes = doc.content[0].content.as_ref().unwrap();
11871        // Should have: "before " (plain), "bold italic" (strong+em), " after" (plain)
11872        assert!(
11873            nodes.len() >= 3,
11874            "expected at least 3 nodes, got {}",
11875            nodes.len()
11876        );
11877        assert_eq!(nodes[0].text.as_deref(), Some("before "));
11878        assert_eq!(nodes[1].text.as_deref(), Some("bold italic"));
11879        let mark_types: Vec<&str> = nodes[1]
11880            .marks
11881            .as_ref()
11882            .unwrap()
11883            .iter()
11884            .map(|m| m.mark_type.as_str())
11885            .collect();
11886        assert!(
11887            mark_types.contains(&"strong") && mark_types.contains(&"em"),
11888            "middle node should have strong+em, got: {mark_types:?}"
11889        );
11890        assert_eq!(nodes[2].text.as_deref(), Some(" after"));
11891    }
11892
11893    #[test]
11894    fn annotation_mark_round_trip() {
11895        // Issue #364: annotation marks were silently dropped
11896        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11897          {"type":"text","text":"highlighted text","marks":[
11898            {"type":"annotation","attrs":{"id":"abc123","annotationType":"inlineComment"}}
11899          ]}
11900        ]}]}"#;
11901        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11902
11903        let md = adf_to_markdown(&doc).unwrap();
11904        assert!(
11905            md.contains("annotation-id="),
11906            "JFM should contain annotation-id, got: {md}"
11907        );
11908
11909        let round_tripped = markdown_to_adf(&md).unwrap();
11910        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11911        assert_eq!(text_node.text.as_deref(), Some("highlighted text"));
11912        let marks = text_node.marks.as_ref().expect("should have marks");
11913        let ann = marks
11914            .iter()
11915            .find(|m| m.mark_type == "annotation")
11916            .expect("should have annotation mark");
11917        let attrs = ann.attrs.as_ref().unwrap();
11918        assert_eq!(attrs["id"], "abc123");
11919        assert_eq!(attrs["annotationType"], "inlineComment");
11920    }
11921
11922    #[test]
11923    fn annotation_mark_with_bold() {
11924        // Annotation + bold should both survive round-trip
11925        let doc = AdfDocument {
11926            version: 1,
11927            doc_type: "doc".to_string(),
11928            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11929                "bold comment",
11930                vec![
11931                    AdfMark::strong(),
11932                    AdfMark::annotation("def456", "inlineComment"),
11933                ],
11934            )])],
11935        };
11936        let md = adf_to_markdown(&doc).unwrap();
11937        let round_tripped = markdown_to_adf(&md).unwrap();
11938        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11939        let marks = text_node.marks.as_ref().expect("should have marks");
11940        assert!(
11941            marks.iter().any(|m| m.mark_type == "strong"),
11942            "should have strong mark"
11943        );
11944        assert!(
11945            marks.iter().any(|m| m.mark_type == "annotation"),
11946            "should have annotation mark"
11947        );
11948    }
11949
11950    #[test]
11951    fn annotation_and_link_marks_both_preserved() {
11952        // Issue #390: text with both annotation and link marks loses link on round-trip
11953        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11954          {"type":"text","text":"HANGUL-8","marks":[
11955            {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"5ca7425e-34cd-48d3-b4eb-9873ac8b20e0"}},
11956            {"type":"link","attrs":{"href":"https://zd.atlassian.net/browse/HANG-8"}}
11957          ]}
11958        ]}]}"#;
11959        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11960        let md = adf_to_markdown(&doc).unwrap();
11961        // Should contain both annotation attrs and link syntax
11962        assert!(
11963            md.contains("annotation-id="),
11964            "JFM should contain annotation-id, got: {md}"
11965        );
11966        assert!(
11967            md.contains("](https://"),
11968            "JFM should contain link href, got: {md}"
11969        );
11970        let round_tripped = markdown_to_adf(&md).unwrap();
11971        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11972        let marks = text_node.marks.as_ref().expect("should have marks");
11973        assert!(
11974            marks.iter().any(|m| m.mark_type == "annotation"),
11975            "should have annotation mark, got: {:?}",
11976            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
11977        );
11978        assert!(
11979            marks.iter().any(|m| m.mark_type == "link"),
11980            "should have link mark, got: {:?}",
11981            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
11982        );
11983    }
11984
11985    #[test]
11986    fn annotation_and_code_marks_both_preserved() {
11987        // Issue #508: annotation mark dropped when combined with code mark
11988        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11989          {"type":"text","text":"some text with "},
11990          {"type":"text","text":"annotated code","marks":[
11991            {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"aabbccdd-1234-5678-abcd-000000000001"}},
11992            {"type":"code"}
11993          ]},
11994          {"type":"text","text":" remaining text"}
11995        ]}]}"#;
11996        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11997        let md = adf_to_markdown(&doc).unwrap();
11998        assert!(
11999            md.contains("annotation-id="),
12000            "JFM should contain annotation-id, got: {md}"
12001        );
12002        assert!(
12003            md.contains('`'),
12004            "JFM should contain backticks for code, got: {md}"
12005        );
12006
12007        let round_tripped = markdown_to_adf(&md).unwrap();
12008        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12009        // Find the text node with "annotated code"
12010        let code_node = nodes
12011            .iter()
12012            .find(|n| n.text.as_deref() == Some("annotated code"))
12013            .expect("should have 'annotated code' text node");
12014        let marks = code_node.marks.as_ref().expect("should have marks");
12015        assert!(
12016            marks.iter().any(|m| m.mark_type == "annotation"),
12017            "should have annotation mark, got: {:?}",
12018            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12019        );
12020        assert!(
12021            marks.iter().any(|m| m.mark_type == "code"),
12022            "should have code mark, got: {:?}",
12023            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12024        );
12025        let ann = marks.iter().find(|m| m.mark_type == "annotation").unwrap();
12026        let attrs = ann.attrs.as_ref().unwrap();
12027        assert_eq!(attrs["id"], "aabbccdd-1234-5678-abcd-000000000001");
12028        assert_eq!(attrs["annotationType"], "inlineComment");
12029    }
12030
12031    #[test]
12032    fn annotation_and_code_and_link_marks_all_preserved() {
12033        // annotation + code + link should all survive round-trip
12034        let doc = AdfDocument {
12035            version: 1,
12036            doc_type: "doc".to_string(),
12037            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12038                "linked code",
12039                vec![
12040                    AdfMark::annotation("ann-001", "inlineComment"),
12041                    AdfMark::code(),
12042                    AdfMark::link("https://example.com"),
12043                ],
12044            )])],
12045        };
12046        let md = adf_to_markdown(&doc).unwrap();
12047        assert!(
12048            md.contains("annotation-id="),
12049            "JFM should contain annotation-id, got: {md}"
12050        );
12051        assert!(md.contains('`'), "JFM should contain backticks, got: {md}");
12052        assert!(
12053            md.contains("](https://example.com)"),
12054            "JFM should contain link, got: {md}"
12055        );
12056
12057        let round_tripped = markdown_to_adf(&md).unwrap();
12058        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12059        let marks = text_node.marks.as_ref().expect("should have marks");
12060        assert!(
12061            marks.iter().any(|m| m.mark_type == "annotation"),
12062            "should have annotation mark, got: {:?}",
12063            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12064        );
12065        assert!(
12066            marks.iter().any(|m| m.mark_type == "code"),
12067            "should have code mark, got: {:?}",
12068            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12069        );
12070        assert!(
12071            marks.iter().any(|m| m.mark_type == "link"),
12072            "should have link mark, got: {:?}",
12073            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12074        );
12075    }
12076
12077    #[test]
12078    fn multiple_annotations_and_code_mark_preserved() {
12079        // Multiple annotation marks on a code node should all survive
12080        let doc = AdfDocument {
12081            version: 1,
12082            doc_type: "doc".to_string(),
12083            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12084                "doubly annotated",
12085                vec![
12086                    AdfMark::annotation("ann-aaa", "inlineComment"),
12087                    AdfMark::annotation("ann-bbb", "inlineComment"),
12088                    AdfMark::code(),
12089                ],
12090            )])],
12091        };
12092        let md = adf_to_markdown(&doc).unwrap();
12093        assert!(
12094            md.contains("ann-aaa"),
12095            "JFM should contain first annotation id, got: {md}"
12096        );
12097        assert!(
12098            md.contains("ann-bbb"),
12099            "JFM should contain second annotation id, got: {md}"
12100        );
12101
12102        let round_tripped = markdown_to_adf(&md).unwrap();
12103        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12104        let marks = text_node.marks.as_ref().expect("should have marks");
12105        let ann_marks: Vec<_> = marks
12106            .iter()
12107            .filter(|m| m.mark_type == "annotation")
12108            .collect();
12109        assert_eq!(
12110            ann_marks.len(),
12111            2,
12112            "should have 2 annotation marks, got: {}",
12113            ann_marks.len()
12114        );
12115        assert!(
12116            marks.iter().any(|m| m.mark_type == "code"),
12117            "should have code mark"
12118        );
12119    }
12120
12121    #[test]
12122    fn underline_and_link_marks_both_preserved() {
12123        // Underline + link should also coexist
12124        let doc = AdfDocument {
12125            version: 1,
12126            doc_type: "doc".to_string(),
12127            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12128                "click here",
12129                vec![AdfMark::underline(), AdfMark::link("https://example.com")],
12130            )])],
12131        };
12132        let md = adf_to_markdown(&doc).unwrap();
12133        assert!(md.contains("underline"), "should have underline attr: {md}");
12134        assert!(
12135            md.contains("](https://example.com)"),
12136            "should have link: {md}"
12137        );
12138        let round_tripped = markdown_to_adf(&md).unwrap();
12139        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12140        let marks = text_node.marks.as_ref().expect("should have marks");
12141        assert!(marks.iter().any(|m| m.mark_type == "underline"));
12142        assert!(marks.iter().any(|m| m.mark_type == "link"));
12143    }
12144
12145    #[test]
12146    fn annotation_link_and_bold_all_preserved() {
12147        // All three marks should coexist
12148        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12149          {"type":"text","text":"important","marks":[
12150            {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"abc"}},
12151            {"type":"link","attrs":{"href":"https://example.com"}},
12152            {"type":"strong"}
12153          ]}
12154        ]}]}"#;
12155        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12156        let md = adf_to_markdown(&doc).unwrap();
12157        let round_tripped = markdown_to_adf(&md).unwrap();
12158        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12159        let marks = text_node.marks.as_ref().expect("should have marks");
12160        assert!(
12161            marks.iter().any(|m| m.mark_type == "annotation"),
12162            "should have annotation"
12163        );
12164        assert!(
12165            marks.iter().any(|m| m.mark_type == "link"),
12166            "should have link"
12167        );
12168        assert!(
12169            marks.iter().any(|m| m.mark_type == "strong"),
12170            "should have strong"
12171        );
12172    }
12173
12174    #[test]
12175    fn multiple_annotation_marks_round_trip() {
12176        // Issue #439: multiple annotation marks on same text node — all but last dropped
12177        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12178          {"type":"text","text":"some annotated text","marks":[
12179            {"type":"annotation","attrs":{"id":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","annotationType":"inlineComment"}},
12180            {"type":"annotation","attrs":{"id":"ffffffff-1111-2222-3333-444444444444","annotationType":"inlineComment"}}
12181          ]}
12182        ]}]}"#;
12183        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12184
12185        let md = adf_to_markdown(&doc).unwrap();
12186        assert!(
12187            md.contains("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"),
12188            "JFM should contain first annotation id, got: {md}"
12189        );
12190        assert!(
12191            md.contains("ffffffff-1111-2222-3333-444444444444"),
12192            "JFM should contain second annotation id, got: {md}"
12193        );
12194
12195        let round_tripped = markdown_to_adf(&md).unwrap();
12196        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12197        assert_eq!(text_node.text.as_deref(), Some("some annotated text"));
12198        let marks = text_node.marks.as_ref().expect("should have marks");
12199        let annotations: Vec<_> = marks
12200            .iter()
12201            .filter(|m| m.mark_type == "annotation")
12202            .collect();
12203        assert_eq!(
12204            annotations.len(),
12205            2,
12206            "should have 2 annotation marks, got: {annotations:?}"
12207        );
12208        let ids: Vec<_> = annotations
12209            .iter()
12210            .map(|a| a.attrs.as_ref().unwrap()["id"].as_str().unwrap())
12211            .collect();
12212        assert!(ids.contains(&"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"));
12213        assert!(ids.contains(&"ffffffff-1111-2222-3333-444444444444"));
12214    }
12215
12216    #[test]
12217    fn three_annotation_marks_round_trip() {
12218        // Verify three overlapping annotations all survive
12219        let doc = AdfDocument {
12220            version: 1,
12221            doc_type: "doc".to_string(),
12222            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12223                "triple annotated",
12224                vec![
12225                    AdfMark::annotation("id-1", "inlineComment"),
12226                    AdfMark::annotation("id-2", "inlineComment"),
12227                    AdfMark::annotation("id-3", "inlineComment"),
12228                ],
12229            )])],
12230        };
12231        let md = adf_to_markdown(&doc).unwrap();
12232        let round_tripped = markdown_to_adf(&md).unwrap();
12233        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12234        let marks = text_node.marks.as_ref().expect("should have marks");
12235        let annotations: Vec<_> = marks
12236            .iter()
12237            .filter(|m| m.mark_type == "annotation")
12238            .collect();
12239        assert_eq!(
12240            annotations.len(),
12241            3,
12242            "should have 3 annotation marks, got: {annotations:?}"
12243        );
12244    }
12245
12246    #[test]
12247    fn multiple_annotations_with_bold_round_trip() {
12248        // Multiple annotations + bold should all survive
12249        let doc = AdfDocument {
12250            version: 1,
12251            doc_type: "doc".to_string(),
12252            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12253                "bold double annotated",
12254                vec![
12255                    AdfMark::strong(),
12256                    AdfMark::annotation("ann-a", "inlineComment"),
12257                    AdfMark::annotation("ann-b", "inlineComment"),
12258                ],
12259            )])],
12260        };
12261        let md = adf_to_markdown(&doc).unwrap();
12262        let round_tripped = markdown_to_adf(&md).unwrap();
12263        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12264        let marks = text_node.marks.as_ref().expect("should have marks");
12265        assert!(
12266            marks.iter().any(|m| m.mark_type == "strong"),
12267            "should have strong mark"
12268        );
12269        let annotations: Vec<_> = marks
12270            .iter()
12271            .filter(|m| m.mark_type == "annotation")
12272            .collect();
12273        assert_eq!(
12274            annotations.len(),
12275            2,
12276            "should have 2 annotation marks, got: {annotations:?}"
12277        );
12278    }
12279
12280    #[test]
12281    fn multiple_annotations_with_link_round_trip() {
12282        // Multiple annotations + link should all survive
12283        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12284          {"type":"text","text":"linked text","marks":[
12285            {"type":"annotation","attrs":{"id":"ann-x","annotationType":"inlineComment"}},
12286            {"type":"annotation","attrs":{"id":"ann-y","annotationType":"inlineComment"}},
12287            {"type":"link","attrs":{"href":"https://example.com"}}
12288          ]}
12289        ]}]}"#;
12290        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12291        let md = adf_to_markdown(&doc).unwrap();
12292        let round_tripped = markdown_to_adf(&md).unwrap();
12293        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12294        let marks = text_node.marks.as_ref().expect("should have marks");
12295        assert!(
12296            marks.iter().any(|m| m.mark_type == "link"),
12297            "should have link mark"
12298        );
12299        let annotations: Vec<_> = marks
12300            .iter()
12301            .filter(|m| m.mark_type == "annotation")
12302            .collect();
12303        assert_eq!(
12304            annotations.len(),
12305            2,
12306            "should have 2 annotation marks, got: {annotations:?}"
12307        );
12308    }
12309
12310    // ── Issue #471: annotation marks on non-text inline nodes ─────────
12311
12312    #[test]
12313    fn annotation_on_emoji_round_trip() {
12314        // Issue #471: annotation mark on emoji node should survive round-trip
12315        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12316          {"type":"emoji","attrs":{"id":"1f4dd","shortName":":memo:","text":"📝"},"marks":[
12317            {"type":"annotation","attrs":{"id":"ccddee11-2233-4455-aabb-ccddee112233","annotationType":"inlineComment"}}
12318          ]},
12319          {"type":"text","text":" annotated text","marks":[
12320            {"type":"annotation","attrs":{"id":"ccddee11-2233-4455-aabb-ccddee112233","annotationType":"inlineComment"}}
12321          ]}
12322        ]}]}"#;
12323        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12324        let md = adf_to_markdown(&doc).unwrap();
12325        assert!(
12326            md.contains("annotation-id="),
12327            "JFM should contain annotation-id for emoji, got: {md}"
12328        );
12329
12330        let round_tripped = markdown_to_adf(&md).unwrap();
12331        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12332
12333        // Emoji node should retain annotation mark
12334        let emoji_node = nodes.iter().find(|n| n.node_type == "emoji").unwrap();
12335        let emoji_marks = emoji_node.marks.as_ref().expect("emoji should have marks");
12336        assert!(
12337            emoji_marks.iter().any(|m| m.mark_type == "annotation"),
12338            "emoji should have annotation mark, got: {emoji_marks:?}"
12339        );
12340        let ann = emoji_marks
12341            .iter()
12342            .find(|m| m.mark_type == "annotation")
12343            .unwrap();
12344        assert_eq!(
12345            ann.attrs.as_ref().unwrap()["id"],
12346            "ccddee11-2233-4455-aabb-ccddee112233"
12347        );
12348
12349        // Text node should also retain annotation mark
12350        let text_node = nodes.iter().find(|n| n.node_type == "text").unwrap();
12351        let text_marks = text_node.marks.as_ref().expect("text should have marks");
12352        assert!(
12353            text_marks.iter().any(|m| m.mark_type == "annotation"),
12354            "text should have annotation mark"
12355        );
12356    }
12357
12358    #[test]
12359    fn annotation_on_status_round_trip() {
12360        let mut status = AdfNode::status("In Progress", "blue");
12361        status.marks = Some(vec![AdfMark::annotation("ann-status-1", "inlineComment")]);
12362
12363        let doc = AdfDocument {
12364            version: 1,
12365            doc_type: "doc".to_string(),
12366            content: vec![AdfNode::paragraph(vec![status])],
12367        };
12368        let md = adf_to_markdown(&doc).unwrap();
12369        assert!(
12370            md.contains("annotation-id="),
12371            "JFM should contain annotation-id for status, got: {md}"
12372        );
12373
12374        let round_tripped = markdown_to_adf(&md).unwrap();
12375        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12376        let status_node = nodes.iter().find(|n| n.node_type == "status").unwrap();
12377        let marks = status_node
12378            .marks
12379            .as_ref()
12380            .expect("status should have marks");
12381        assert!(
12382            marks.iter().any(|m| m.mark_type == "annotation"),
12383            "status should have annotation mark, got: {marks:?}"
12384        );
12385    }
12386
12387    #[test]
12388    fn annotation_on_date_round_trip() {
12389        let mut date = AdfNode::date("1704067200000");
12390        date.marks = Some(vec![AdfMark::annotation("ann-date-1", "inlineComment")]);
12391
12392        let doc = AdfDocument {
12393            version: 1,
12394            doc_type: "doc".to_string(),
12395            content: vec![AdfNode::paragraph(vec![date])],
12396        };
12397        let md = adf_to_markdown(&doc).unwrap();
12398        assert!(
12399            md.contains("annotation-id="),
12400            "JFM should contain annotation-id for date, got: {md}"
12401        );
12402
12403        let round_tripped = markdown_to_adf(&md).unwrap();
12404        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12405        let date_node = nodes.iter().find(|n| n.node_type == "date").unwrap();
12406        let marks = date_node.marks.as_ref().expect("date should have marks");
12407        assert!(
12408            marks.iter().any(|m| m.mark_type == "annotation"),
12409            "date should have annotation mark, got: {marks:?}"
12410        );
12411    }
12412
12413    #[test]
12414    fn annotation_on_mention_round_trip() {
12415        let mut mention = AdfNode::mention("user-123", "@Alice");
12416        mention.marks = Some(vec![AdfMark::annotation("ann-mention-1", "inlineComment")]);
12417
12418        let doc = AdfDocument {
12419            version: 1,
12420            doc_type: "doc".to_string(),
12421            content: vec![AdfNode::paragraph(vec![mention])],
12422        };
12423        let md = adf_to_markdown(&doc).unwrap();
12424        assert!(
12425            md.contains("annotation-id="),
12426            "JFM should contain annotation-id for mention, got: {md}"
12427        );
12428
12429        let round_tripped = markdown_to_adf(&md).unwrap();
12430        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12431        let mention_node = nodes.iter().find(|n| n.node_type == "mention").unwrap();
12432        let marks = mention_node
12433            .marks
12434            .as_ref()
12435            .expect("mention should have marks");
12436        assert!(
12437            marks.iter().any(|m| m.mark_type == "annotation"),
12438            "mention should have annotation mark, got: {marks:?}"
12439        );
12440    }
12441
12442    #[test]
12443    fn annotation_on_inline_card_round_trip() {
12444        let mut card = AdfNode::inline_card("https://example.com");
12445        card.marks = Some(vec![AdfMark::annotation("ann-card-1", "inlineComment")]);
12446
12447        let doc = AdfDocument {
12448            version: 1,
12449            doc_type: "doc".to_string(),
12450            content: vec![AdfNode::paragraph(vec![card])],
12451        };
12452        let md = adf_to_markdown(&doc).unwrap();
12453        assert!(
12454            md.contains("annotation-id="),
12455            "JFM should contain annotation-id for inlineCard, got: {md}"
12456        );
12457
12458        let round_tripped = markdown_to_adf(&md).unwrap();
12459        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12460        let card_node = nodes.iter().find(|n| n.node_type == "inlineCard").unwrap();
12461        let marks = card_node
12462            .marks
12463            .as_ref()
12464            .expect("inlineCard should have marks");
12465        assert!(
12466            marks.iter().any(|m| m.mark_type == "annotation"),
12467            "inlineCard should have annotation mark, got: {marks:?}"
12468        );
12469    }
12470
12471    #[test]
12472    fn annotation_on_placeholder_round_trip() {
12473        let mut placeholder = AdfNode::placeholder("Enter text here");
12474        placeholder.marks = Some(vec![AdfMark::annotation("ann-ph-1", "inlineComment")]);
12475
12476        let doc = AdfDocument {
12477            version: 1,
12478            doc_type: "doc".to_string(),
12479            content: vec![AdfNode::paragraph(vec![placeholder])],
12480        };
12481        let md = adf_to_markdown(&doc).unwrap();
12482        assert!(
12483            md.contains("annotation-id="),
12484            "JFM should contain annotation-id for placeholder, got: {md}"
12485        );
12486
12487        let round_tripped = markdown_to_adf(&md).unwrap();
12488        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12489        let ph_node = nodes.iter().find(|n| n.node_type == "placeholder").unwrap();
12490        let marks = ph_node
12491            .marks
12492            .as_ref()
12493            .expect("placeholder should have marks");
12494        assert!(
12495            marks.iter().any(|m| m.mark_type == "annotation"),
12496            "placeholder should have annotation mark, got: {marks:?}"
12497        );
12498    }
12499
12500    #[test]
12501    fn multiple_annotations_on_emoji_round_trip() {
12502        // Multiple annotation marks on a single emoji node
12503        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12504          {"type":"emoji","attrs":{"shortName":":fire:","text":"🔥"},"marks":[
12505            {"type":"annotation","attrs":{"id":"ann-1","annotationType":"inlineComment"}},
12506            {"type":"annotation","attrs":{"id":"ann-2","annotationType":"inlineComment"}}
12507          ]}
12508        ]}]}"#;
12509        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12510        let md = adf_to_markdown(&doc).unwrap();
12511
12512        let round_tripped = markdown_to_adf(&md).unwrap();
12513        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12514        let emoji_node = nodes.iter().find(|n| n.node_type == "emoji").unwrap();
12515        let marks = emoji_node.marks.as_ref().expect("emoji should have marks");
12516        let annotations: Vec<_> = marks
12517            .iter()
12518            .filter(|m| m.mark_type == "annotation")
12519            .collect();
12520        assert_eq!(
12521            annotations.len(),
12522            2,
12523            "emoji should have 2 annotation marks, got: {annotations:?}"
12524        );
12525    }
12526
12527    #[test]
12528    fn emoji_without_annotation_unchanged() {
12529        // Ensure emoji nodes without annotation marks are not affected
12530        let doc = AdfDocument {
12531            version: 1,
12532            doc_type: "doc".to_string(),
12533            content: vec![AdfNode::paragraph(vec![AdfNode::emoji(":fire:")])],
12534        };
12535        let md = adf_to_markdown(&doc).unwrap();
12536        // Should NOT have bracketed span wrapping
12537        assert!(
12538            !md.contains('['),
12539            "emoji without annotation should not be wrapped in brackets, got: {md}"
12540        );
12541        assert!(md.contains(":fire:"));
12542    }
12543
12544    // ── Inline directive tests (Tier 4) ───────────────────────────────
12545
12546    #[test]
12547    fn status_directive() {
12548        let doc = markdown_to_adf("The ticket is :status[In Progress]{color=blue}.").unwrap();
12549        let content = doc.content[0].content.as_ref().unwrap();
12550        assert_eq!(content[1].node_type, "status");
12551        assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "In Progress");
12552        assert_eq!(content[1].attrs.as_ref().unwrap()["color"], "blue");
12553    }
12554
12555    #[test]
12556    fn adf_status_to_markdown() {
12557        let doc = AdfDocument {
12558            version: 1,
12559            doc_type: "doc".to_string(),
12560            content: vec![AdfNode::paragraph(vec![AdfNode::status("Done", "green")])],
12561        };
12562        let md = adf_to_markdown(&doc).unwrap();
12563        assert!(md.contains(":status[Done]{color=green}"));
12564    }
12565
12566    #[test]
12567    fn round_trip_status() {
12568        let md = "The ticket is :status[In Progress]{color=blue}.\n";
12569        let doc = markdown_to_adf(md).unwrap();
12570        let result = adf_to_markdown(&doc).unwrap();
12571        assert!(result.contains(":status[In Progress]{color=blue}"));
12572    }
12573
12574    #[test]
12575    fn status_with_style_and_localid_roundtrips() {
12576        let adf = AdfDocument {
12577            version: 1,
12578            doc_type: "doc".to_string(),
12579            content: vec![AdfNode::paragraph(vec![{
12580                let mut node = AdfNode::status("open", "green");
12581                node.attrs.as_mut().unwrap()["style"] =
12582                    serde_json::Value::String("bold".to_string());
12583                node.attrs.as_mut().unwrap()["localId"] =
12584                    serde_json::Value::String("d2205ca5-84b9-4950-a730-bfe550fc146b".to_string());
12585                node
12586            }])],
12587        };
12588
12589        let md = adf_to_markdown(&adf).unwrap();
12590        assert!(
12591            md.contains("style=bold"),
12592            "Markdown should contain style attr: {md}"
12593        );
12594        assert!(
12595            md.contains("localId=d2205ca5"),
12596            "Markdown should contain localId attr: {md}"
12597        );
12598
12599        let rt = markdown_to_adf(&md).unwrap();
12600        let status = &rt.content[0].content.as_ref().unwrap()[0];
12601        let attrs = status.attrs.as_ref().unwrap();
12602        assert_eq!(attrs["text"], "open");
12603        assert_eq!(attrs["color"], "green");
12604        assert_eq!(attrs["style"], "bold");
12605        assert_eq!(
12606            attrs["localId"], "d2205ca5-84b9-4950-a730-bfe550fc146b",
12607            "localId should be preserved, got: {}",
12608            attrs["localId"]
12609        );
12610    }
12611
12612    #[test]
12613    fn status_without_style_still_works() {
12614        let md = ":status[Done]{color=green}\n";
12615        let doc = markdown_to_adf(md).unwrap();
12616        let status = &doc.content[0].content.as_ref().unwrap()[0];
12617        let attrs = status.attrs.as_ref().unwrap();
12618        assert_eq!(attrs["text"], "Done");
12619        assert_eq!(attrs["color"], "green");
12620        // No style attr — should not be present
12621        assert!(
12622            attrs.get("style").is_none() || attrs["style"].is_null(),
12623            "style should not be set when not provided"
12624        );
12625    }
12626
12627    #[test]
12628    fn strip_local_ids_removes_localid_from_status() {
12629        let adf = AdfDocument {
12630            version: 1,
12631            doc_type: "doc".to_string(),
12632            content: vec![AdfNode::paragraph(vec![{
12633                let mut node = AdfNode::status("open", "green");
12634                node.attrs.as_mut().unwrap()["localId"] =
12635                    serde_json::Value::String("real-uuid-here".to_string());
12636                node
12637            }])],
12638        };
12639        let opts = RenderOptions {
12640            strip_local_ids: true,
12641        };
12642        let md = adf_to_markdown_with_options(&adf, &opts).unwrap();
12643        assert!(
12644            !md.contains("localId"),
12645            "localId should be stripped, got: {md}"
12646        );
12647        assert!(md.contains("color=green"), "color should be preserved");
12648    }
12649
12650    #[test]
12651    fn strip_local_ids_removes_localid_from_table() {
12652        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"}]}]}]}]}]}"#;
12653        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12654        let opts = RenderOptions {
12655            strip_local_ids: true,
12656        };
12657        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12658        assert!(
12659            !md.contains("localId"),
12660            "localId should be stripped from table, got: {md}"
12661        );
12662        assert!(md.contains("layout=default"), "layout should be preserved");
12663    }
12664
12665    #[test]
12666    fn default_options_preserve_localid() {
12667        let adf = AdfDocument {
12668            version: 1,
12669            doc_type: "doc".to_string(),
12670            content: vec![AdfNode::paragraph(vec![{
12671                let mut node = AdfNode::status("open", "green");
12672                node.attrs.as_mut().unwrap()["localId"] =
12673                    serde_json::Value::String("real-uuid-here".to_string());
12674                node
12675            }])],
12676        };
12677        let md = adf_to_markdown(&adf).unwrap();
12678        assert!(
12679            md.contains("localId=real-uuid-here"),
12680            "Default should preserve localId, got: {md}"
12681        );
12682    }
12683
12684    #[test]
12685    fn mention_localid_roundtrip() {
12686        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"mention","attrs":{"id":"user123","text":"@Alice","localId":"m-001"}}]}]}"#;
12687        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12688        let md = adf_to_markdown(&doc).unwrap();
12689        assert!(
12690            md.contains("localId=m-001"),
12691            "mention should have localId in md: {md}"
12692        );
12693        let rt = markdown_to_adf(&md).unwrap();
12694        let mention = &rt.content[0].content.as_ref().unwrap()[0];
12695        assert_eq!(mention.attrs.as_ref().unwrap()["localId"], "m-001");
12696    }
12697
12698    #[test]
12699    fn date_localid_roundtrip() {
12700        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
12701        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12702        let md = adf_to_markdown(&doc).unwrap();
12703        assert!(
12704            md.contains("localId=d-001"),
12705            "date should have localId in md: {md}"
12706        );
12707        let rt = markdown_to_adf(&md).unwrap();
12708        let date = &rt.content[0].content.as_ref().unwrap()[0];
12709        assert_eq!(date.attrs.as_ref().unwrap()["localId"], "d-001");
12710    }
12711
12712    #[test]
12713    fn emoji_localid_roundtrip() {
12714        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"emoji","attrs":{"shortName":":smile:","localId":"e-001"}}]}]}"#;
12715        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12716        let md = adf_to_markdown(&doc).unwrap();
12717        assert!(
12718            md.contains("localId=e-001"),
12719            "emoji should have localId in md: {md}"
12720        );
12721        let rt = markdown_to_adf(&md).unwrap();
12722        let emoji = &rt.content[0].content.as_ref().unwrap()[0];
12723        assert_eq!(emoji.attrs.as_ref().unwrap()["localId"], "e-001");
12724    }
12725
12726    #[test]
12727    fn inline_card_localid_roundtrip() {
12728        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"inlineCard","attrs":{"url":"https://example.com","localId":"c-001"}}]}]}"#;
12729        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12730        let md = adf_to_markdown(&doc).unwrap();
12731        assert!(
12732            md.contains("localId=c-001"),
12733            "inlineCard should have localId in md: {md}"
12734        );
12735        let rt = markdown_to_adf(&md).unwrap();
12736        let card = &rt.content[0].content.as_ref().unwrap()[0];
12737        assert_eq!(card.attrs.as_ref().unwrap()["localId"], "c-001");
12738    }
12739
12740    #[test]
12741    fn strip_local_ids_removes_from_mention() {
12742        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"mention","attrs":{"id":"user123","text":"@Alice","localId":"m-001"}}]}]}"#;
12743        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12744        let opts = RenderOptions {
12745            strip_local_ids: true,
12746        };
12747        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12748        assert!(
12749            !md.contains("localId"),
12750            "localId should be stripped from mention: {md}"
12751        );
12752        assert!(md.contains("id=user123"), "other attrs should be preserved");
12753    }
12754
12755    #[test]
12756    fn strip_local_ids_removes_from_date() {
12757        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
12758        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12759        let opts = RenderOptions {
12760            strip_local_ids: true,
12761        };
12762        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12763        assert!(
12764            !md.contains("localId"),
12765            "localId should be stripped from date: {md}"
12766        );
12767    }
12768
12769    #[test]
12770    fn strip_local_ids_removes_from_block_attrs() {
12771        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"p-001"},"content":[{"type":"text","text":"hello"}]}]}"#;
12772        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12773        let opts = RenderOptions {
12774            strip_local_ids: true,
12775        };
12776        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12777        assert!(
12778            !md.contains("localId"),
12779            "localId should be stripped from block attrs: {md}"
12780        );
12781    }
12782
12783    #[test]
12784    fn table_cell_localid_roundtrip() {
12785        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"}]}]}]}]}]}"#;
12786        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12787        let md = adf_to_markdown(&doc).unwrap();
12788        assert!(
12789            md.contains("localId=tc-001"),
12790            "tableCell should have localId in md: {md}"
12791        );
12792        let rt = markdown_to_adf(&md).unwrap();
12793        let cell = &rt.content[0].content.as_ref().unwrap()[0]
12794            .content
12795            .as_ref()
12796            .unwrap()[0];
12797        assert_eq!(
12798            cell.attrs.as_ref().unwrap()["localId"],
12799            "tc-001",
12800            "tableCell localId should round-trip"
12801        );
12802    }
12803
12804    #[test]
12805    fn table_cell_border_mark_roundtrip() {
12806        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"}]}]}]}]}]}"##;
12807        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12808        let md = adf_to_markdown(&doc).unwrap();
12809        assert!(
12810            md.contains("border-color=#ff000033"),
12811            "tableCell should have border-color in md: {md}"
12812        );
12813        assert!(
12814            md.contains("border-size=2"),
12815            "tableCell should have border-size in md: {md}"
12816        );
12817        let rt = markdown_to_adf(&md).unwrap();
12818        let cell = &rt.content[0].content.as_ref().unwrap()[0]
12819            .content
12820            .as_ref()
12821            .unwrap()[0];
12822        let marks = cell.marks.as_ref().expect("tableCell should have marks");
12823        assert_eq!(marks.len(), 1);
12824        assert_eq!(marks[0].mark_type, "border");
12825        let attrs = marks[0].attrs.as_ref().unwrap();
12826        assert_eq!(attrs["color"], "#ff000033");
12827        assert_eq!(attrs["size"], 2);
12828    }
12829
12830    #[test]
12831    fn table_header_border_mark_roundtrip() {
12832        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"}]}]}]}]}]}"##;
12833        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12834        let md = adf_to_markdown(&doc).unwrap();
12835        assert!(md.contains("border-color=#0000ff"), "md: {md}");
12836        assert!(md.contains("border-size=3"), "md: {md}");
12837        let rt = markdown_to_adf(&md).unwrap();
12838        let cell = &rt.content[0].content.as_ref().unwrap()[0]
12839            .content
12840            .as_ref()
12841            .unwrap()[0];
12842        assert_eq!(cell.node_type, "tableHeader");
12843        let marks = cell.marks.as_ref().expect("tableHeader should have marks");
12844        assert_eq!(marks[0].mark_type, "border");
12845        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#0000ff");
12846        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
12847    }
12848
12849    #[test]
12850    fn table_cell_border_mark_with_attrs_roundtrip() {
12851        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"}]}]}]}]}]}"##;
12852        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12853        let md = adf_to_markdown(&doc).unwrap();
12854        assert!(md.contains("bg=#e6fcff"), "md: {md}");
12855        assert!(md.contains("colspan=2"), "md: {md}");
12856        assert!(md.contains("border-color=#ff000033"), "md: {md}");
12857        let rt = markdown_to_adf(&md).unwrap();
12858        let cell = &rt.content[0].content.as_ref().unwrap()[0]
12859            .content
12860            .as_ref()
12861            .unwrap()[0];
12862        assert_eq!(cell.attrs.as_ref().unwrap()["background"], "#e6fcff");
12863        assert_eq!(cell.attrs.as_ref().unwrap()["colspan"], 2);
12864        let marks = cell.marks.as_ref().expect("should have marks");
12865        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff000033");
12866    }
12867
12868    #[test]
12869    fn table_cell_no_border_mark_unchanged() {
12870        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"}]}]}]}]}]}"#;
12871        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12872        let md = adf_to_markdown(&doc).unwrap();
12873        assert!(
12874            !md.contains("border-color"),
12875            "no border attrs expected: {md}"
12876        );
12877        let rt = markdown_to_adf(&md).unwrap();
12878        let cell = &rt.content[0].content.as_ref().unwrap()[0]
12879            .content
12880            .as_ref()
12881            .unwrap()[0];
12882        assert!(cell.marks.is_none(), "no marks expected on plain cell");
12883    }
12884
12885    #[test]
12886    fn table_cell_border_size_only_defaults_color() {
12887        // border-size without border-color should still produce a border mark
12888        // with the default color
12889        let md = "::::table\n:::tr\n:::td{border-size=3}\ncell\n:::\n:::\n::::\n";
12890        let doc = markdown_to_adf(md).unwrap();
12891        let cell = &doc.content[0].content.as_ref().unwrap()[0]
12892            .content
12893            .as_ref()
12894            .unwrap()[0];
12895        let marks = cell.marks.as_ref().expect("should have border mark");
12896        assert_eq!(marks[0].mark_type, "border");
12897        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#000000");
12898        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
12899    }
12900
12901    #[test]
12902    fn table_cell_border_color_only_defaults_size() {
12903        // border-color without border-size should default size to 1
12904        let md = "::::table\n:::tr\n:::td{border-color=#ff0000}\ncell\n:::\n:::\n::::\n";
12905        let doc = markdown_to_adf(md).unwrap();
12906        let cell = &doc.content[0].content.as_ref().unwrap()[0]
12907            .content
12908            .as_ref()
12909            .unwrap()[0];
12910        let marks = cell.marks.as_ref().expect("should have border mark");
12911        assert_eq!(marks[0].mark_type, "border");
12912        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff0000");
12913        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 1);
12914    }
12915
12916    #[test]
12917    fn media_file_border_mark_roundtrip() {
12918        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}}]}]}]}"##;
12919        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12920        let md = adf_to_markdown(&doc).unwrap();
12921        assert!(
12922            md.contains("border-color=#091e4224"),
12923            "media should have border-color in md: {md}"
12924        );
12925        assert!(
12926            md.contains("border-size=2"),
12927            "media should have border-size in md: {md}"
12928        );
12929        let rt = markdown_to_adf(&md).unwrap();
12930        let media_single = &rt.content[0];
12931        let media = &media_single.content.as_ref().unwrap()[0];
12932        assert_eq!(media.node_type, "media");
12933        let marks = media.marks.as_ref().expect("media should have marks");
12934        assert_eq!(marks.len(), 1);
12935        assert_eq!(marks[0].mark_type, "border");
12936        let attrs = marks[0].attrs.as_ref().unwrap();
12937        assert_eq!(attrs["color"], "#091e4224");
12938        assert_eq!(attrs["size"], 2);
12939    }
12940
12941    #[test]
12942    fn media_external_border_mark_roundtrip() {
12943        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}}]}]}]}"##;
12944        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12945        let md = adf_to_markdown(&doc).unwrap();
12946        assert!(
12947            md.contains("border-color=#ff0000"),
12948            "external media should have border-color in md: {md}"
12949        );
12950        assert!(
12951            md.contains("border-size=3"),
12952            "external media should have border-size in md: {md}"
12953        );
12954        let rt = markdown_to_adf(&md).unwrap();
12955        let media = &rt.content[0].content.as_ref().unwrap()[0];
12956        let marks = media.marks.as_ref().expect("media should have marks");
12957        assert_eq!(marks[0].mark_type, "border");
12958        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff0000");
12959        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
12960    }
12961
12962    #[test]
12963    fn media_file_no_border_mark_unchanged() {
12964        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}}]}]}"#;
12965        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12966        let md = adf_to_markdown(&doc).unwrap();
12967        assert!(
12968            !md.contains("border-color"),
12969            "no border attrs expected: {md}"
12970        );
12971        let rt = markdown_to_adf(&md).unwrap();
12972        let media = &rt.content[0].content.as_ref().unwrap()[0];
12973        assert!(media.marks.is_none(), "no marks expected on plain media");
12974    }
12975
12976    #[test]
12977    fn media_border_size_only_defaults_color() {
12978        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}}]}]}]}"#;
12979        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12980        let md = adf_to_markdown(&doc).unwrap();
12981        assert!(md.contains("border-size=4"), "md: {md}");
12982        let rt = markdown_to_adf(&md).unwrap();
12983        let media = &rt.content[0].content.as_ref().unwrap()[0];
12984        let marks = media.marks.as_ref().expect("should have border mark");
12985        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#000000");
12986        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 4);
12987    }
12988
12989    #[test]
12990    fn media_border_color_only_defaults_size() {
12991        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"}}]}]}]}"##;
12992        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12993        let md = adf_to_markdown(&doc).unwrap();
12994        assert!(md.contains("border-color=#00ff00"), "md: {md}");
12995        let rt = markdown_to_adf(&md).unwrap();
12996        let media = &rt.content[0].content.as_ref().unwrap()[0];
12997        let marks = media.marks.as_ref().expect("should have border mark");
12998        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#00ff00");
12999        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 1);
13000    }
13001
13002    #[test]
13003    fn media_border_with_other_attrs_roundtrip() {
13004        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}}]}]}]}"##;
13005        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13006        let md = adf_to_markdown(&doc).unwrap();
13007        assert!(md.contains("layout=wide"), "md: {md}");
13008        assert!(md.contains("mediaWidth=600"), "md: {md}");
13009        assert!(md.contains("border-color=#091e4224"), "md: {md}");
13010        assert!(md.contains("border-size=2"), "md: {md}");
13011        let rt = markdown_to_adf(&md).unwrap();
13012        let ms = &rt.content[0];
13013        assert_eq!(ms.attrs.as_ref().unwrap()["layout"], "wide");
13014        let media = &ms.content.as_ref().unwrap()[0];
13015        let marks = media.marks.as_ref().expect("should have marks");
13016        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#091e4224");
13017        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 2);
13018    }
13019
13020    #[test]
13021    fn table_row_localid_roundtrip() {
13022        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"}]}]}]}]}]}"#;
13023        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13024        let md = adf_to_markdown(&doc).unwrap();
13025        assert!(
13026            md.contains("localId=tr-001"),
13027            "tableRow should have localId in md: {md}"
13028        );
13029        let rt = markdown_to_adf(&md).unwrap();
13030        let row = &rt.content[0].content.as_ref().unwrap()[0];
13031        assert_eq!(
13032            row.attrs.as_ref().unwrap()["localId"],
13033            "tr-001",
13034            "tableRow localId should round-trip"
13035        );
13036    }
13037
13038    #[test]
13039    fn list_item_localid_roundtrip() {
13040        // listItem localId is emitted as trailing inline attrs and parsed back
13041        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"}]}]}]}]}"#;
13042        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13043        let md = adf_to_markdown(&doc).unwrap();
13044        assert!(
13045            md.contains("localId=li-001"),
13046            "listItem should have localId in md: {md}"
13047        );
13048        // Verify localId is on the listItem, NOT promoted to bulletList
13049        let rt = markdown_to_adf(&md).unwrap();
13050        let list = &rt.content[0];
13051        assert!(
13052            list.attrs.is_none() || list.attrs.as_ref().unwrap().get("localId").is_none(),
13053            "bulletList should NOT have localId: {:?}",
13054            list.attrs
13055        );
13056        let item = &list.content.as_ref().unwrap()[0];
13057        assert_eq!(
13058            item.attrs.as_ref().unwrap()["localId"],
13059            "li-001",
13060            "listItem should have localId=li-001"
13061        );
13062    }
13063
13064    #[test]
13065    fn list_item_localid_not_promoted_to_parent() {
13066        // Verify localId stays on listItem and doesn't leak to parent list
13067        let md = "- item {localId=li-002}\n";
13068        let doc = markdown_to_adf(md).unwrap();
13069        let list = &doc.content[0];
13070        assert!(
13071            list.attrs.is_none(),
13072            "bulletList should have no attrs: {:?}",
13073            list.attrs
13074        );
13075        let item = &list.content.as_ref().unwrap()[0];
13076        assert_eq!(item.attrs.as_ref().unwrap()["localId"], "li-002");
13077    }
13078
13079    #[test]
13080    fn ordered_list_item_localid_roundtrip() {
13081        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"}]}]}]}]}"#;
13082        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13083        let md = adf_to_markdown(&doc).unwrap();
13084        assert!(md.contains("localId=oli-001"), "md: {md}");
13085        let rt = markdown_to_adf(&md).unwrap();
13086        let item = &rt.content[0].content.as_ref().unwrap()[0];
13087        assert_eq!(item.attrs.as_ref().unwrap()["localId"], "oli-001");
13088    }
13089
13090    #[test]
13091    fn task_item_localid_roundtrip() {
13092        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"}]}]}]}]}"#;
13093        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13094        let md = adf_to_markdown(&doc).unwrap();
13095        assert!(md.contains("localId=ti-001"), "md: {md}");
13096        let rt = markdown_to_adf(&md).unwrap();
13097        let item = &rt.content[0].content.as_ref().unwrap()[0];
13098        assert_eq!(item.attrs.as_ref().unwrap()["localId"], "ti-001");
13099    }
13100
13101    /// Issue #447: taskList with empty-string localId and taskItems with
13102    /// short numeric localIds must survive a full round-trip.
13103    #[test]
13104    fn task_list_short_localid_roundtrip() {
13105        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"}]}]}]}"#;
13106        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13107        let md = adf_to_markdown(&doc).unwrap();
13108        // Both taskItem localIds should appear in the markdown
13109        assert!(md.contains("localId=42"), "localId=42 missing: {md}");
13110        assert!(md.contains("localId=99"), "localId=99 missing: {md}");
13111        // Empty-string localId should NOT appear as {localId=}
13112        assert!(
13113            !md.contains("localId=}"),
13114            "empty localId should not be emitted: {md}"
13115        );
13116        let rt = markdown_to_adf(&md).unwrap();
13117        let task_list = &rt.content[0];
13118        assert_eq!(task_list.node_type, "taskList");
13119        // No spurious extra nodes from {localId=}
13120        assert_eq!(rt.content.len(), 1, "should be exactly one top-level node");
13121        let items = task_list.content.as_ref().unwrap();
13122        assert_eq!(items.len(), 2);
13123        // First taskItem: localId=42, state=TODO, no content
13124        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13125        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
13126        assert!(
13127            items[0].content.is_none(),
13128            "empty taskItem should have no content: {:?}",
13129            items[0].content
13130        );
13131        // Second taskItem: localId=99, state=DONE, content with text
13132        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "99");
13133        assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
13134        let content = items[1].content.as_ref().unwrap();
13135        assert_eq!(content.len(), 1);
13136        assert_eq!(content[0].text.as_deref(), Some("done task"));
13137    }
13138
13139    /// Issue #507: numeric localId on taskItem with hardBreak must survive
13140    /// round-trip — the {localId=…} suffix lands on the continuation line
13141    /// and must still be extracted by the parser.
13142    #[test]
13143    fn task_item_numeric_localid_with_hardbreak_roundtrip() {
13144        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!!)"}]}]}]}]}"#;
13145        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13146        let md = adf_to_markdown(&doc).unwrap();
13147        // localId must appear in the markdown output
13148        assert!(md.contains("localId=42"), "localId=42 missing: {md}");
13149        // Round-trip back to ADF
13150        let rt = markdown_to_adf(&md).unwrap();
13151        assert_eq!(rt.content.len(), 1, "exactly one top-level node");
13152        let task_list = &rt.content[0];
13153        assert_eq!(task_list.node_type, "taskList");
13154        let items = task_list.content.as_ref().unwrap();
13155        assert_eq!(items.len(), 1);
13156        // localId preserved
13157        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13158        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DONE");
13159        // Content structure preserved: paragraph with link + hardBreak + text
13160        let para = &items[0].content.as_ref().unwrap()[0];
13161        assert_eq!(para.node_type, "paragraph");
13162        let inlines = para.content.as_ref().unwrap();
13163        assert_eq!(inlines[0].node_type, "text");
13164        assert_eq!(
13165            inlines[0].text.as_deref(),
13166            Some("Engineering Onboarding Link")
13167        );
13168        assert_eq!(inlines[1].node_type, "hardBreak");
13169        assert_eq!(inlines[2].node_type, "text");
13170        assert_eq!(
13171            inlines[2].text.as_deref(),
13172            Some("(This has links to all the various useful tools!!)")
13173        );
13174        // The {localId=…} must not appear as literal text in the ADF output
13175        let rt_json = serde_json::to_string(&rt).unwrap();
13176        assert!(
13177            !rt_json.contains("{localId="),
13178            "localId attr syntax should not leak into ADF text: {rt_json}"
13179        );
13180    }
13181
13182    /// Issue #507: multiple taskItems with hardBreaks and numeric localIds.
13183    #[test]
13184    fn task_item_multiple_hardbreak_localids_roundtrip() {
13185        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"}]}]}]}]}"#;
13186        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13187        let md = adf_to_markdown(&doc).unwrap();
13188        assert!(md.contains("localId=42"), "localId=42 missing: {md}");
13189        assert!(md.contains("localId=67"), "localId=67 missing: {md}");
13190        let rt = markdown_to_adf(&md).unwrap();
13191        let items = rt.content[0].content.as_ref().unwrap();
13192        assert_eq!(items.len(), 2);
13193        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13194        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "67");
13195        // Verify hardBreak content structure for both items
13196        for item in items {
13197            let para = &item.content.as_ref().unwrap()[0];
13198            assert_eq!(para.node_type, "paragraph");
13199            let inlines = para.content.as_ref().unwrap();
13200            assert_eq!(inlines[1].node_type, "hardBreak");
13201        }
13202    }
13203
13204    /// Issue #521: sibling taskItems with numeric localIds and hardBreak —
13205    /// unwrapped inline content.  The hardBreak continuation line must be
13206    /// indented so it stays within the list item, and both localIds must
13207    /// survive the round-trip.
13208    #[test]
13209    fn task_item_sibling_localid_hardbreak_unwrapped_roundtrip() {
13210        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"}]}]}]}"#;
13211        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13212        let md = adf_to_markdown(&doc).unwrap();
13213        // Continuation line must be indented
13214        assert!(
13215            md.contains("  (parenthetical"),
13216            "continuation line should be 2-space indented: {md}"
13217        );
13218        assert!(md.contains("localId=42"), "localId=42 missing: {md}");
13219        assert!(md.contains("localId=69"), "localId=69 missing: {md}");
13220        let rt = markdown_to_adf(&md).unwrap();
13221        // Must remain a single taskList with 2 items
13222        assert_eq!(
13223            rt.content.len(),
13224            1,
13225            "should be one taskList: {:#?}",
13226            rt.content
13227        );
13228        assert_eq!(rt.content[0].node_type, "taskList");
13229        let items = rt.content[0].content.as_ref().unwrap();
13230        assert_eq!(items.len(), 2, "should have 2 taskItems");
13231        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13232        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "69");
13233        // Verify first item has hardBreak
13234        let first_content = items[0].content.as_ref().unwrap();
13235        assert!(
13236            first_content.iter().any(|n| n.node_type == "hardBreak"),
13237            "first item should contain hardBreak"
13238        );
13239        // Verify second item content
13240        let second_content = items[1].content.as_ref().unwrap();
13241        assert_eq!(second_content[0].node_type, "text");
13242        assert_eq!(
13243            second_content[0].text.as_deref().unwrap(),
13244            "second task item"
13245        );
13246    }
13247
13248    /// Issue #521: sibling taskItems with paragraph-wrapped content and
13249    /// hardBreak — localIds must not be swapped or lost.
13250    #[test]
13251    fn task_item_sibling_localid_hardbreak_paragraph_roundtrip() {
13252        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"}]}]}]}]}"#;
13253        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13254        let md = adf_to_markdown(&doc).unwrap();
13255        let rt = markdown_to_adf(&md).unwrap();
13256        assert_eq!(
13257            rt.content.len(),
13258            1,
13259            "should be one taskList: {:#?}",
13260            rt.content
13261        );
13262        let items = rt.content[0].content.as_ref().unwrap();
13263        assert_eq!(items.len(), 2);
13264        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13265        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "69");
13266    }
13267
13268    /// Issue #521: three sibling taskItems — the middle one has a hardBreak.
13269    /// Ensures localIds don't leak between adjacent items.
13270    #[test]
13271    fn task_item_three_siblings_middle_hardbreak_roundtrip() {
13272        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"}]}]}]}"#;
13273        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13274        let md = adf_to_markdown(&doc).unwrap();
13275        let rt = markdown_to_adf(&md).unwrap();
13276        assert_eq!(rt.content.len(), 1);
13277        let items = rt.content[0].content.as_ref().unwrap();
13278        assert_eq!(items.len(), 3);
13279        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "10");
13280        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "20");
13281        assert_eq!(items[2].attrs.as_ref().unwrap()["localId"], "30");
13282        // Middle item should have hardBreak
13283        let mid_content = items[1].content.as_ref().unwrap();
13284        assert!(mid_content.iter().any(|n| n.node_type == "hardBreak"));
13285    }
13286
13287    /// Issue #447: regression — taskList with empty localId must not inject
13288    /// a spurious paragraph.
13289    #[test]
13290    fn task_list_empty_localid_no_spurious_paragraph() {
13291        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"}]}]}]}"#;
13292        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13293        let md = adf_to_markdown(&doc).unwrap();
13294        assert!(
13295            !md.contains("{localId=}"),
13296            "empty localId should not be emitted: {md}"
13297        );
13298        let rt = markdown_to_adf(&md).unwrap();
13299        assert_eq!(
13300            rt.content.len(),
13301            1,
13302            "no spurious paragraph: {:#?}",
13303            rt.content
13304        );
13305        assert_eq!(rt.content[0].node_type, "taskList");
13306    }
13307
13308    /// Issue #447: taskList localId should be stripped when strip_local_ids is set.
13309    #[test]
13310    fn task_list_localid_stripped() {
13311        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"}]}]}]}]}"#;
13312        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13313        let opts = RenderOptions {
13314            strip_local_ids: true,
13315        };
13316        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13317        assert!(!md.contains("localId"), "localId should be stripped: {md}");
13318    }
13319
13320    /// Issue #447: taskItem with no content still emits localId.
13321    #[test]
13322    fn task_item_no_content_emits_localid() {
13323        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"}}]}]}"#;
13324        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13325        let md = adf_to_markdown(&doc).unwrap();
13326        assert!(
13327            md.contains("localId=abc"),
13328            "localId should be emitted even without content: {md}"
13329        );
13330        let rt = markdown_to_adf(&md).unwrap();
13331        let item = &rt.content[0].content.as_ref().unwrap()[0];
13332        assert_eq!(item.attrs.as_ref().unwrap()["localId"], "abc");
13333        assert!(item.content.is_none(), "should have no content");
13334    }
13335
13336    /// Issue #447: taskList localId roundtrips through block attrs.
13337    #[test]
13338    fn task_list_localid_roundtrip() {
13339        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"}]}]}]}]}"#;
13340        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13341        let md = adf_to_markdown(&doc).unwrap();
13342        assert!(
13343            md.contains("localId=tl-xyz"),
13344            "taskList localId missing: {md}"
13345        );
13346        let rt = markdown_to_adf(&md).unwrap();
13347        assert_eq!(
13348            rt.content[0].attrs.as_ref().unwrap()["localId"],
13349            "tl-xyz",
13350            "taskList localId should survive round-trip"
13351        );
13352    }
13353
13354    /// Issue #478: taskItem with paragraph wrapper (no localId) preserves wrapper on round-trip.
13355    #[test]
13356    fn task_item_paragraph_wrapper_roundtrip_no_localid() {
13357        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"}]}]}]}]}"#;
13358        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13359        let md = adf_to_markdown(&doc).unwrap();
13360        assert!(
13361            md.contains("paraLocalId=_"),
13362            "should emit paraLocalId=_ sentinel: {md}"
13363        );
13364        let rt = markdown_to_adf(&md).unwrap();
13365        let item = &rt.content[0].content.as_ref().unwrap()[0];
13366        let content = item.content.as_ref().unwrap();
13367        assert_eq!(content.len(), 1, "should have one child: {content:#?}");
13368        assert_eq!(
13369            content[0].node_type, "paragraph",
13370            "child should be a paragraph: {content:#?}"
13371        );
13372        let para_content = content[0].content.as_ref().unwrap();
13373        assert_eq!(
13374            para_content[0].text.as_deref(),
13375            Some("A task with paragraph wrapper")
13376        );
13377        // Paragraph should have no attrs (localId was absent in the original)
13378        assert!(
13379            content[0].attrs.is_none(),
13380            "paragraph should have no attrs: {:?}",
13381            content[0].attrs
13382        );
13383    }
13384
13385    /// Issue #478: taskItem with paragraph wrapper AND paraLocalId preserves both.
13386    #[test]
13387    fn task_item_paragraph_wrapper_roundtrip_with_localid() {
13388        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"}]}]}]}]}"#;
13389        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13390        let md = adf_to_markdown(&doc).unwrap();
13391        assert!(
13392            md.contains("paraLocalId=p-001"),
13393            "should emit paraLocalId=p-001: {md}"
13394        );
13395        let rt = markdown_to_adf(&md).unwrap();
13396        let item = &rt.content[0].content.as_ref().unwrap()[0];
13397        let content = item.content.as_ref().unwrap();
13398        assert_eq!(content[0].node_type, "paragraph");
13399        assert_eq!(
13400            content[0].attrs.as_ref().unwrap()["localId"],
13401            "p-001",
13402            "paragraph localId should be preserved"
13403        );
13404    }
13405
13406    /// Issue #478: taskItem WITHOUT paragraph wrapper (unwrapped inline) still round-trips correctly.
13407    #[test]
13408    fn task_item_unwrapped_inline_no_paragraph_on_roundtrip() {
13409        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"}]}]}]}"#;
13410        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13411        let md = adf_to_markdown(&doc).unwrap();
13412        assert!(
13413            !md.contains("paraLocalId"),
13414            "should NOT emit paraLocalId for unwrapped inline: {md}"
13415        );
13416        let rt = markdown_to_adf(&md).unwrap();
13417        let item = &rt.content[0].content.as_ref().unwrap()[0];
13418        let content = item.content.as_ref().unwrap();
13419        assert_eq!(
13420            content[0].node_type, "text",
13421            "should remain unwrapped: {content:#?}"
13422        );
13423    }
13424
13425    /// Issue #478: DONE taskItem with paragraph wrapper round-trips.
13426    #[test]
13427    fn task_item_done_paragraph_wrapper_roundtrip() {
13428        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"}]}]}]}]}"#;
13429        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13430        let md = adf_to_markdown(&doc).unwrap();
13431        assert!(md.contains("- [x]"), "should render as done: {md}");
13432        let rt = markdown_to_adf(&md).unwrap();
13433        let item = &rt.content[0].content.as_ref().unwrap()[0];
13434        assert_eq!(item.attrs.as_ref().unwrap()["state"], "DONE");
13435        let content = item.content.as_ref().unwrap();
13436        assert_eq!(content[0].node_type, "paragraph");
13437    }
13438
13439    /// Issue #478: mixed taskItems — some with paragraph wrapper, some without.
13440    #[test]
13441    fn task_item_mixed_paragraph_and_unwrapped_roundtrip() {
13442        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"}]}]}]}"#;
13443        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13444        let md = adf_to_markdown(&doc).unwrap();
13445        let rt = markdown_to_adf(&md).unwrap();
13446        let items = rt.content[0].content.as_ref().unwrap();
13447        assert_eq!(items.len(), 2);
13448        // First item: paragraph wrapper preserved
13449        let c1 = items[0].content.as_ref().unwrap();
13450        assert_eq!(
13451            c1[0].node_type, "paragraph",
13452            "first item should have paragraph wrapper"
13453        );
13454        // Second item: no paragraph wrapper
13455        let c2 = items[1].content.as_ref().unwrap();
13456        assert_eq!(
13457            c2[0].node_type, "text",
13458            "second item should remain unwrapped"
13459        );
13460    }
13461
13462    /// Issue #478: taskItem with paragraph wrapper containing marks round-trips.
13463    #[test]
13464    fn task_item_paragraph_wrapper_with_marks_roundtrip() {
13465        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"}]}]}]}]}]}"#;
13466        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13467        let md = adf_to_markdown(&doc).unwrap();
13468        let rt = markdown_to_adf(&md).unwrap();
13469        let item = &rt.content[0].content.as_ref().unwrap()[0];
13470        let content = item.content.as_ref().unwrap();
13471        assert_eq!(content[0].node_type, "paragraph");
13472        let para_children = content[0].content.as_ref().unwrap();
13473        assert!(
13474            para_children.len() >= 2,
13475            "paragraph should contain multiple inline nodes"
13476        );
13477    }
13478
13479    /// Issue #478: strip_local_ids suppresses the paraLocalId=_ sentinel too.
13480    #[test]
13481    fn task_item_paragraph_wrapper_stripped_with_option() {
13482        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"}]}]}]}]}"#;
13483        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13484        let opts = RenderOptions {
13485            strip_local_ids: true,
13486        };
13487        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13488        assert!(
13489            !md.contains("paraLocalId"),
13490            "paraLocalId should be stripped: {md}"
13491        );
13492        assert!(
13493            !md.contains("localId"),
13494            "all localIds should be stripped: {md}"
13495        );
13496    }
13497
13498    #[test]
13499    fn trailing_space_preserved_with_hex_localid() {
13500        // Issue #449: trailing whitespace stripped from text node
13501        // when listItem has a hex-format localId (no hyphens)
13502        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 "}]}]}]}]}"#;
13503        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13504        let md = adf_to_markdown(&doc).unwrap();
13505        let rt = markdown_to_adf(&md).unwrap();
13506        let item = &rt.content[0].content.as_ref().unwrap()[0];
13507        assert_eq!(
13508            item.attrs.as_ref().unwrap()["localId"],
13509            "aabb112233cc",
13510            "localId should round-trip"
13511        );
13512        let para = &item.content.as_ref().unwrap()[0];
13513        let inlines = para.content.as_ref().unwrap();
13514        let last = inlines.last().unwrap();
13515        assert!(
13516            last.text.as_deref().unwrap_or("").ends_with(' '),
13517            "trailing space should be preserved, got nodes: {:?}",
13518            inlines
13519                .iter()
13520                .map(|n| (&n.node_type, &n.text))
13521                .collect::<Vec<_>>()
13522        );
13523    }
13524
13525    #[test]
13526    fn extract_trailing_local_id_preserves_trailing_space() {
13527        // Issue #449: only strip the single separator space before {localId=...}
13528        let (before, lid, _) = extract_trailing_local_id("trailing space  {localId=aabb112233cc}");
13529        assert_eq!(before, "trailing space ");
13530        assert_eq!(lid.as_deref(), Some("aabb112233cc"));
13531    }
13532
13533    #[test]
13534    fn extract_trailing_local_id_no_trailing_space() {
13535        let (before, lid, _) = extract_trailing_local_id("text {localId=abc123}");
13536        assert_eq!(before, "text");
13537        assert_eq!(lid.as_deref(), Some("abc123"));
13538    }
13539
13540    #[test]
13541    fn extract_trailing_local_id_no_attrs() {
13542        let (before, lid, pid) = extract_trailing_local_id("plain text");
13543        assert_eq!(before, "plain text");
13544        assert!(lid.is_none());
13545        assert!(pid.is_none());
13546    }
13547
13548    #[test]
13549    fn list_item_localid_stripped() {
13550        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"}]}]}]}]}"#;
13551        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13552        let opts = RenderOptions {
13553            strip_local_ids: true,
13554        };
13555        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13556        assert!(!md.contains("localId"), "localId should be stripped: {md}");
13557    }
13558
13559    #[test]
13560    fn paragraph_localid_in_list_item_roundtrip() {
13561        // Issue #417: paragraph.attrs.localId dropped in listItem context
13562        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"}]}]}]}]}"#;
13563        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13564        let md = adf_to_markdown(&doc).unwrap();
13565        assert!(
13566            md.contains("paraLocalId=para-001"),
13567            "paragraph localId should be in md: {md}"
13568        );
13569        let rt = markdown_to_adf(&md).unwrap();
13570        let item = &rt.content[0].content.as_ref().unwrap()[0];
13571        assert_eq!(
13572            item.attrs.as_ref().unwrap()["localId"],
13573            "item-001",
13574            "listItem localId should survive"
13575        );
13576        let para = &item.content.as_ref().unwrap()[0];
13577        assert_eq!(
13578            para.attrs.as_ref().unwrap()["localId"],
13579            "para-001",
13580            "paragraph localId should survive round-trip"
13581        );
13582    }
13583
13584    #[test]
13585    fn paragraph_localid_in_ordered_list_item_roundtrip() {
13586        // Issue #417: paragraph localId in ordered list
13587        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"}]}]}]}]}"#;
13588        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13589        let md = adf_to_markdown(&doc).unwrap();
13590        assert!(md.contains("paraLocalId=op-001"), "md: {md}");
13591        let rt = markdown_to_adf(&md).unwrap();
13592        let item = &rt.content[0].content.as_ref().unwrap()[0];
13593        assert_eq!(item.attrs.as_ref().unwrap()["localId"], "oli-001");
13594        let para = &item.content.as_ref().unwrap()[0];
13595        assert_eq!(para.attrs.as_ref().unwrap()["localId"], "op-001");
13596    }
13597
13598    #[test]
13599    fn paragraph_localid_only_in_list_item() {
13600        // paragraph has localId but listItem does not
13601        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"}]}]}]}]}"#;
13602        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13603        let md = adf_to_markdown(&doc).unwrap();
13604        assert!(
13605            md.contains("paraLocalId=para-only"),
13606            "paragraph localId should be emitted: {md}"
13607        );
13608        let rt = markdown_to_adf(&md).unwrap();
13609        let item = &rt.content[0].content.as_ref().unwrap()[0];
13610        assert!(item.attrs.is_none(), "listItem should have no attrs");
13611        let para = &item.content.as_ref().unwrap()[0];
13612        assert_eq!(para.attrs.as_ref().unwrap()["localId"], "para-only");
13613    }
13614
13615    #[test]
13616    fn paragraph_localid_in_table_header_roundtrip() {
13617        // Issue #417: paragraph.attrs.localId dropped in tableHeader context
13618        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"}]}]}]}]}]}"#;
13619        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13620        let md = adf_to_markdown(&doc).unwrap();
13621        // Should use directive form (not pipe table) to preserve paragraph localId
13622        assert!(
13623            md.contains("localId=aaaa-aaaa"),
13624            "paragraph localId should be in md: {md}"
13625        );
13626        let rt = markdown_to_adf(&md).unwrap();
13627        let cell = &rt.content[0].content.as_ref().unwrap()[0]
13628            .content
13629            .as_ref()
13630            .unwrap()[0];
13631        let para = &cell.content.as_ref().unwrap()[0];
13632        assert_eq!(
13633            para.attrs.as_ref().unwrap()["localId"],
13634            "aaaa-aaaa",
13635            "paragraph localId should survive round-trip in tableHeader"
13636        );
13637    }
13638
13639    #[test]
13640    fn paragraph_localid_in_table_cell_roundtrip() {
13641        // Issue #417: paragraph localId in tableCell forces directive table
13642        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"}]}]}]}]}]}"#;
13643        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13644        let md = adf_to_markdown(&doc).unwrap();
13645        assert!(
13646            md.contains("localId=cell-para"),
13647            "paragraph localId should be in md: {md}"
13648        );
13649        let rt = markdown_to_adf(&md).unwrap();
13650        // Data row -> cell -> paragraph
13651        let cell = &rt.content[0].content.as_ref().unwrap()[1]
13652            .content
13653            .as_ref()
13654            .unwrap()[0];
13655        let para = &cell.content.as_ref().unwrap()[0];
13656        assert_eq!(
13657            para.attrs.as_ref().unwrap()["localId"],
13658            "cell-para",
13659            "paragraph localId should survive round-trip in tableCell"
13660        );
13661    }
13662
13663    #[test]
13664    fn nbsp_paragraph_with_localid_roundtrip() {
13665        // Issue #417: nbsp paragraph localId emitted as text instead of attrs
13666        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"nbsp-para"},"content":[{"type":"text","text":"\u00a0"}]}]}"#;
13667        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13668        let md = adf_to_markdown(&doc).unwrap();
13669        assert!(
13670            md.contains("::paragraph["),
13671            "nbsp should use directive form: {md}"
13672        );
13673        assert!(
13674            md.contains("localId=nbsp-para"),
13675            "localId should be in directive: {md}"
13676        );
13677        let rt = markdown_to_adf(&md).unwrap();
13678        let para = &rt.content[0];
13679        assert_eq!(
13680            para.attrs.as_ref().unwrap()["localId"],
13681            "nbsp-para",
13682            "localId should survive round-trip"
13683        );
13684        let text = para.content.as_ref().unwrap()[0].text.as_ref().unwrap();
13685        assert_eq!(text, "\u{00a0}", "nbsp should survive");
13686    }
13687
13688    #[test]
13689    fn empty_paragraph_with_localid_roundtrip() {
13690        // Empty paragraph directive with localId
13691        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"empty-para"}}]}"#;
13692        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13693        let md = adf_to_markdown(&doc).unwrap();
13694        assert!(
13695            md.contains("::paragraph{localId=empty-para}"),
13696            "empty paragraph should include localId in directive: {md}"
13697        );
13698        let rt = markdown_to_adf(&md).unwrap();
13699        assert_eq!(
13700            rt.content[0].attrs.as_ref().unwrap()["localId"],
13701            "empty-para"
13702        );
13703    }
13704
13705    #[test]
13706    fn paragraph_localid_stripped_from_list_item() {
13707        // strip_local_ids should also strip paraLocalId
13708        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"}]}]}]}]}"#;
13709        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13710        let opts = RenderOptions {
13711            strip_local_ids: true,
13712        };
13713        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13714        assert!(!md.contains("localId"), "localId should be stripped: {md}");
13715        assert!(
13716            !md.contains("paraLocalId"),
13717            "paraLocalId should be stripped: {md}"
13718        );
13719    }
13720
13721    #[test]
13722    fn date_directive() {
13723        let doc = markdown_to_adf("Due by :date[2026-04-15].").unwrap();
13724        let content = doc.content[0].content.as_ref().unwrap();
13725        assert_eq!(content[1].node_type, "date");
13726        // ISO date is converted to epoch milliseconds
13727        assert_eq!(
13728            content[1].attrs.as_ref().unwrap()["timestamp"],
13729            "1776211200000"
13730        );
13731    }
13732
13733    #[test]
13734    fn adf_date_to_markdown() {
13735        // ADF dates use epoch ms; renderer converts back to ISO with timestamp attr
13736        let doc = AdfDocument {
13737            version: 1,
13738            doc_type: "doc".to_string(),
13739            content: vec![AdfNode::paragraph(vec![AdfNode::date("1776211200000")])],
13740        };
13741        let md = adf_to_markdown(&doc).unwrap();
13742        assert!(md.contains(":date[2026-04-15]{timestamp=1776211200000}"));
13743    }
13744
13745    #[test]
13746    fn adf_date_iso_passthrough() {
13747        // If ADF already has ISO date (legacy), pass through
13748        let doc = AdfDocument {
13749            version: 1,
13750            doc_type: "doc".to_string(),
13751            content: vec![AdfNode::paragraph(vec![AdfNode::date("2026-04-15")])],
13752        };
13753        let md = adf_to_markdown(&doc).unwrap();
13754        assert!(md.contains(":date[2026-04-15]{timestamp=2026-04-15}"));
13755    }
13756
13757    #[test]
13758    fn round_trip_date() {
13759        let md = "Due by :date[2026-04-15].\n";
13760        let doc = markdown_to_adf(md).unwrap();
13761        let result = adf_to_markdown(&doc).unwrap();
13762        assert!(result.contains(":date[2026-04-15]"));
13763    }
13764
13765    #[test]
13766    fn round_trip_date_non_midnight_timestamp() {
13767        // Issue #409: non-midnight timestamps must survive round-trip
13768        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000"}}]}]}"#;
13769        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13770        let md = adf_to_markdown(&doc).unwrap();
13771        // JFM should include the original timestamp
13772        assert!(
13773            md.contains("timestamp=1700000000000"),
13774            "JFM should preserve original timestamp: {md}"
13775        );
13776        // Round-trip back to ADF
13777        let doc2 = markdown_to_adf(&md).unwrap();
13778        let content = doc2.content[0].content.as_ref().unwrap();
13779        assert_eq!(
13780            content[0].attrs.as_ref().unwrap()["timestamp"],
13781            "1700000000000",
13782            "Round-trip must preserve original non-midnight timestamp"
13783        );
13784    }
13785
13786    #[test]
13787    fn date_epoch_ms_passthrough() {
13788        // If JFM date is already epoch ms, pass through
13789        let doc = markdown_to_adf("Due by :date[1776211200000].").unwrap();
13790        let content = doc.content[0].content.as_ref().unwrap();
13791        assert_eq!(
13792            content[1].attrs.as_ref().unwrap()["timestamp"],
13793            "1776211200000"
13794        );
13795    }
13796
13797    #[test]
13798    fn date_timestamp_attr_preferred_over_content() {
13799        // When timestamp attr is present, it takes priority over the display date
13800        let md = ":date[2023-11-14]{timestamp=1700000000000}\n";
13801        let doc = markdown_to_adf(md).unwrap();
13802        let content = doc.content[0].content.as_ref().unwrap();
13803        assert_eq!(
13804            content[0].attrs.as_ref().unwrap()["timestamp"],
13805            "1700000000000",
13806            "timestamp attr should be used directly"
13807        );
13808    }
13809
13810    #[test]
13811    fn date_without_timestamp_attr_backward_compat() {
13812        // Legacy JFM without timestamp attr still works via iso_date_to_epoch_ms
13813        let md = ":date[2026-04-15]\n";
13814        let doc = markdown_to_adf(md).unwrap();
13815        let content = doc.content[0].content.as_ref().unwrap();
13816        assert_eq!(
13817            content[0].attrs.as_ref().unwrap()["timestamp"],
13818            "1776211200000",
13819            "Should fall back to computing timestamp from date string"
13820        );
13821    }
13822
13823    #[test]
13824    fn date_with_local_id_and_timestamp() {
13825        // Both localId and timestamp should round-trip
13826        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
13827        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13828        let md = adf_to_markdown(&doc).unwrap();
13829        assert!(
13830            md.contains("timestamp=1700000000000"),
13831            "Should contain timestamp: {md}"
13832        );
13833        assert!(md.contains("localId=d-001"), "Should contain localId: {md}");
13834        // Round-trip
13835        let doc2 = markdown_to_adf(&md).unwrap();
13836        let content = doc2.content[0].content.as_ref().unwrap();
13837        let attrs = content[0].attrs.as_ref().unwrap();
13838        assert_eq!(attrs["timestamp"], "1700000000000");
13839        assert_eq!(attrs["localId"], "d-001");
13840    }
13841
13842    #[test]
13843    fn mention_directive() {
13844        let doc = markdown_to_adf("Assigned to :mention[Alice]{id=abc123}.").unwrap();
13845        let content = doc.content[0].content.as_ref().unwrap();
13846        assert_eq!(content[1].node_type, "mention");
13847        assert_eq!(content[1].attrs.as_ref().unwrap()["id"], "abc123");
13848        assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "Alice");
13849    }
13850
13851    #[test]
13852    fn adf_mention_to_markdown() {
13853        let doc = AdfDocument {
13854            version: 1,
13855            doc_type: "doc".to_string(),
13856            content: vec![AdfNode::paragraph(vec![AdfNode::mention(
13857                "abc123", "Alice",
13858            )])],
13859        };
13860        let md = adf_to_markdown(&doc).unwrap();
13861        assert!(md.contains(":mention[Alice]{id=abc123}"));
13862    }
13863
13864    #[test]
13865    fn round_trip_mention() {
13866        let md = "Assigned to :mention[Alice]{id=abc123}.\n";
13867        let doc = markdown_to_adf(md).unwrap();
13868        let result = adf_to_markdown(&doc).unwrap();
13869        assert!(result.contains(":mention[Alice]{id=abc123}"));
13870    }
13871
13872    #[test]
13873    fn mention_with_empty_access_level_round_trips() {
13874        // Issue #363: accessLevel="" produces accessLevel= which failed to parse
13875        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13876          {"type":"mention","attrs":{"id":"61921b41c15977006af2b1d1","text":"@Javier Inchausti","accessLevel":""}}
13877        ]}]}"#;
13878        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13879
13880        let md = adf_to_markdown(&doc).unwrap();
13881        let round_tripped = markdown_to_adf(&md).unwrap();
13882        let mention = &round_tripped.content[0].content.as_ref().unwrap()[0];
13883        assert_eq!(
13884            mention.node_type, "mention",
13885            "mention with empty accessLevel was not parsed as mention, got: {}",
13886            mention.node_type
13887        );
13888    }
13889
13890    #[test]
13891    fn span_with_color() {
13892        let doc = markdown_to_adf("This is :span[red text]{color=#ff5630}.").unwrap();
13893        let content = doc.content[0].content.as_ref().unwrap();
13894        assert_eq!(content[1].node_type, "text");
13895        assert_eq!(content[1].text.as_deref(), Some("red text"));
13896        let marks = content[1].marks.as_ref().unwrap();
13897        assert_eq!(marks[0].mark_type, "textColor");
13898    }
13899
13900    #[test]
13901    fn adf_text_color_to_markdown() {
13902        let doc = AdfDocument {
13903            version: 1,
13904            doc_type: "doc".to_string(),
13905            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
13906                "red text",
13907                vec![AdfMark::text_color("#ff5630")],
13908            )])],
13909        };
13910        let md = adf_to_markdown(&doc).unwrap();
13911        assert!(md.contains(":span[red text]{color=#ff5630}"));
13912    }
13913
13914    #[test]
13915    fn round_trip_span_color() {
13916        let md = "This is :span[red text]{color=#ff5630}.\n";
13917        let doc = markdown_to_adf(md).unwrap();
13918        let result = adf_to_markdown(&doc).unwrap();
13919        assert!(result.contains(":span[red text]{color=#ff5630}"));
13920    }
13921
13922    #[test]
13923    fn text_color_and_link_marks_both_preserved() {
13924        // Issue #405: text with both textColor and link marks loses link on round-trip
13925        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13926          {"type":"text","text":"red link","marks":[
13927            {"type":"link","attrs":{"href":"https://example.com"}},
13928            {"type":"textColor","attrs":{"color":"#ff0000"}}
13929          ]}
13930        ]}]}"##;
13931        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13932        let md = adf_to_markdown(&doc).unwrap();
13933        assert!(
13934            md.contains(":span[red link]{color=#ff0000}"),
13935            "JFM should contain span with color, got: {md}"
13936        );
13937        assert!(
13938            md.contains("](https://example.com)"),
13939            "JFM should contain link href, got: {md}"
13940        );
13941        // Full round-trip: both marks survive
13942        let rt = markdown_to_adf(&md).unwrap();
13943        let text_node = &rt.content[0].content.as_ref().unwrap()[0];
13944        let marks = text_node.marks.as_ref().expect("should have marks");
13945        assert!(
13946            marks.iter().any(|m| m.mark_type == "textColor"),
13947            "should have textColor mark, got: {:?}",
13948            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
13949        );
13950        assert!(
13951            marks.iter().any(|m| m.mark_type == "link"),
13952            "should have link mark, got: {:?}",
13953            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
13954        );
13955        // Verify attribute values survive
13956        let link_mark = marks.iter().find(|m| m.mark_type == "link").unwrap();
13957        assert_eq!(
13958            link_mark.attrs.as_ref().unwrap()["href"],
13959            "https://example.com"
13960        );
13961        let color_mark = marks.iter().find(|m| m.mark_type == "textColor").unwrap();
13962        assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#ff0000");
13963    }
13964
13965    #[test]
13966    fn bg_color_and_link_marks_both_preserved() {
13967        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13968          {"type":"text","text":"highlighted link","marks":[
13969            {"type":"link","attrs":{"href":"https://example.com"}},
13970            {"type":"backgroundColor","attrs":{"color":"#ffff00"}}
13971          ]}
13972        ]}]}"##;
13973        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13974        let md = adf_to_markdown(&doc).unwrap();
13975        assert!(md.contains("bg=#ffff00"), "should have bg color: {md}");
13976        assert!(
13977            md.contains("](https://example.com)"),
13978            "should have link: {md}"
13979        );
13980        let rt = markdown_to_adf(&md).unwrap();
13981        let text_node = &rt.content[0].content.as_ref().unwrap()[0];
13982        let marks = text_node.marks.as_ref().expect("should have marks");
13983        assert!(marks.iter().any(|m| m.mark_type == "backgroundColor"));
13984        assert!(marks.iter().any(|m| m.mark_type == "link"));
13985    }
13986
13987    #[test]
13988    fn text_color_link_and_strong_rendering() {
13989        // Verify textColor + link + strong renders all three formatting elements
13990        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13991          {"type":"text","text":"bold red link","marks":[
13992            {"type":"strong"},
13993            {"type":"link","attrs":{"href":"https://example.com"}},
13994            {"type":"textColor","attrs":{"color":"#ff0000"}}
13995          ]}
13996        ]}]}"##;
13997        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13998        let md = adf_to_markdown(&doc).unwrap();
13999        assert!(
14000            md.starts_with("**") && md.trim().ends_with("**"),
14001            "should have bold wrapping: {md}"
14002        );
14003        assert!(md.contains("color=#ff0000"), "should have color: {md}");
14004        assert!(
14005            md.contains("](https://example.com)"),
14006            "should have link: {md}"
14007        );
14008    }
14009
14010    #[test]
14011    fn subsup_and_link_marks_both_preserved() {
14012        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
14013          {"type":"text","text":"note","marks":[
14014            {"type":"link","attrs":{"href":"https://example.com"}},
14015            {"type":"subsup","attrs":{"type":"sup"}}
14016          ]}
14017        ]}]}"#;
14018        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14019        let md = adf_to_markdown(&doc).unwrap();
14020        assert!(md.contains("sup"), "should have sup: {md}");
14021        assert!(
14022            md.contains("](https://example.com)"),
14023            "should have link: {md}"
14024        );
14025        let rt = markdown_to_adf(&md).unwrap();
14026        let text_node = &rt.content[0].content.as_ref().unwrap()[0];
14027        let marks = text_node.marks.as_ref().expect("should have marks");
14028        assert!(marks.iter().any(|m| m.mark_type == "subsup"));
14029        assert!(marks.iter().any(|m| m.mark_type == "link"));
14030    }
14031
14032    #[test]
14033    fn text_color_without_link_unchanged() {
14034        // Regression guard: textColor without link should still work
14035        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
14036          {"type":"text","text":"just red","marks":[
14037            {"type":"textColor","attrs":{"color":"#ff0000"}}
14038          ]}
14039        ]}]}"##;
14040        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14041        let md = adf_to_markdown(&doc).unwrap();
14042        assert!(md.contains(":span[just red]{color=#ff0000}"), "md: {md}");
14043        assert!(!md.contains("](http"), "should NOT have link syntax: {md}");
14044    }
14045
14046    #[test]
14047    fn inline_extension_directive() {
14048        let doc =
14049            markdown_to_adf("See :extension[fallback]{type=com.app key=widget} here.").unwrap();
14050        let content = doc.content[0].content.as_ref().unwrap();
14051        assert_eq!(content[1].node_type, "inlineExtension");
14052        assert_eq!(
14053            content[1].attrs.as_ref().unwrap()["extensionType"],
14054            "com.app"
14055        );
14056        assert_eq!(content[1].attrs.as_ref().unwrap()["extensionKey"], "widget");
14057    }
14058
14059    #[test]
14060    fn adf_inline_extension_to_markdown() {
14061        let doc = AdfDocument {
14062            version: 1,
14063            doc_type: "doc".to_string(),
14064            content: vec![AdfNode::paragraph(vec![AdfNode::inline_extension(
14065                "com.app",
14066                "widget",
14067                Some("fallback"),
14068            )])],
14069        };
14070        let md = adf_to_markdown(&doc).unwrap();
14071        assert!(md.contains(":extension[fallback]{type=com.app key=widget}"));
14072    }
14073
14074    // ── Helper function tests ──────────────────────────────────────────
14075
14076    #[test]
14077    fn parse_ordered_list_marker_valid() {
14078        let result = parse_ordered_list_marker("1. Hello");
14079        assert_eq!(result, Some((1, "Hello")));
14080    }
14081
14082    #[test]
14083    fn parse_ordered_list_marker_high_number() {
14084        let result = parse_ordered_list_marker("42. Item");
14085        assert_eq!(result, Some((42, "Item")));
14086    }
14087
14088    #[test]
14089    fn parse_ordered_list_marker_not_a_list() {
14090        assert!(parse_ordered_list_marker("not a list").is_none());
14091        assert!(parse_ordered_list_marker("1.no space").is_none());
14092    }
14093
14094    #[test]
14095    fn is_list_start_various() {
14096        assert!(is_list_start("- item"));
14097        assert!(is_list_start("* item"));
14098        assert!(is_list_start("+ item"));
14099        assert!(is_list_start("1. item"));
14100        assert!(!is_list_start("not a list"));
14101    }
14102
14103    #[test]
14104    fn is_horizontal_rule_various() {
14105        assert!(is_horizontal_rule("---"));
14106        assert!(is_horizontal_rule("***"));
14107        assert!(is_horizontal_rule("___"));
14108        assert!(is_horizontal_rule("------"));
14109        assert!(!is_horizontal_rule("--"));
14110        assert!(!is_horizontal_rule("abc"));
14111    }
14112
14113    #[test]
14114    fn is_table_separator_valid() {
14115        assert!(is_table_separator("| --- | --- |"));
14116        assert!(is_table_separator("|:---:|:---|"));
14117        assert!(!is_table_separator("no pipes here"));
14118    }
14119
14120    #[test]
14121    fn parse_table_row_cells() {
14122        let cells = parse_table_row("| A | B | C |");
14123        assert_eq!(cells, vec!["A", "B", "C"]);
14124    }
14125
14126    #[test]
14127    fn parse_table_row_escaped_pipe_in_cell() {
14128        // Issue #579: `\|` inside a cell is a literal pipe, not a column separator.
14129        let cells = parse_table_row(r"| a\|b | c |");
14130        assert_eq!(cells, vec!["a|b", "c"]);
14131    }
14132
14133    #[test]
14134    fn parse_table_row_escaped_pipe_in_code_span() {
14135        // Issue #579: `\|` inside an inline code span is unescaped at the row level.
14136        let cells = parse_table_row(r"| `parser.decode[T\|json]` | other |");
14137        assert_eq!(cells, vec!["`parser.decode[T|json]`", "other"]);
14138    }
14139
14140    #[test]
14141    fn parse_table_row_preserves_other_backslashes() {
14142        // Only `\|` is special at the row-splitting level; other backslashes pass through.
14143        let cells = parse_table_row(r"| a\\b | c\*d |");
14144        assert_eq!(cells, vec![r"a\\b", r"c\*d"]);
14145    }
14146
14147    #[test]
14148    fn parse_image_syntax_valid() {
14149        let result = parse_image_syntax("![alt](url)");
14150        assert_eq!(result, Some(("alt", "url")));
14151    }
14152
14153    #[test]
14154    fn parse_image_syntax_not_image() {
14155        assert!(parse_image_syntax("not an image").is_none());
14156    }
14157
14158    // ── find_closing_paren tests ────────────────────────────────────
14159
14160    #[test]
14161    fn find_closing_paren_simple() {
14162        assert_eq!(find_closing_paren("(hello)", 0), Some(6));
14163    }
14164
14165    #[test]
14166    fn find_closing_paren_nested() {
14167        assert_eq!(find_closing_paren("(a(b)c)", 0), Some(6));
14168    }
14169
14170    #[test]
14171    fn find_closing_paren_unmatched() {
14172        assert_eq!(find_closing_paren("(no close", 0), None);
14173    }
14174
14175    #[test]
14176    fn find_closing_paren_offset() {
14177        // Start scanning from the second '('
14178        assert_eq!(find_closing_paren("xx(inner)", 2), Some(8));
14179    }
14180
14181    // ── Parentheses-in-URL tests (issue #509) ──────────────────────
14182
14183    #[test]
14184    fn try_parse_link_url_with_parens() {
14185        let input = "[here](https://example.com/faq#access-(permissions)-rest)";
14186        let result = try_parse_link(input, 0);
14187        assert_eq!(
14188            result,
14189            Some((
14190                input.len(),
14191                "here",
14192                "https://example.com/faq#access-(permissions)-rest"
14193            ))
14194        );
14195    }
14196
14197    #[test]
14198    fn try_parse_link_url_no_parens() {
14199        let input = "[text](https://example.com)";
14200        let result = try_parse_link(input, 0);
14201        assert_eq!(result, Some((input.len(), "text", "https://example.com")));
14202    }
14203
14204    #[test]
14205    fn try_parse_link_url_with_multiple_nested_parens() {
14206        let input = "[x](http://en.wikipedia.org/wiki/Foo_(bar_(baz)))";
14207        let result = try_parse_link(input, 0);
14208        assert_eq!(
14209            result,
14210            Some((
14211                input.len(),
14212                "x",
14213                "http://en.wikipedia.org/wiki/Foo_(bar_(baz))"
14214            ))
14215        );
14216    }
14217
14218    #[test]
14219    fn parse_image_syntax_url_with_parens() {
14220        let result = parse_image_syntax("![alt](https://example.com/page_(1))");
14221        assert_eq!(result, Some(("alt", "https://example.com/page_(1)")));
14222    }
14223
14224    #[test]
14225    fn parse_image_syntax_url_no_parens() {
14226        let result = parse_image_syntax("![alt](https://example.com)");
14227        assert_eq!(result, Some(("alt", "https://example.com")));
14228    }
14229
14230    #[test]
14231    fn link_with_parens_round_trip() {
14232        let href = "https://example.com/faq#I-need-access-(permissions)-added-in-Monitor";
14233        let mut text_node = AdfNode::text("here");
14234        text_node.marks = Some(vec![AdfMark::link(href)]);
14235        let adf_input = AdfDocument {
14236            version: 1,
14237            doc_type: "doc".to_string(),
14238            content: vec![AdfNode::paragraph(vec![text_node])],
14239        };
14240
14241        let jfm = adf_to_markdown(&adf_input).unwrap();
14242        let adf_output = markdown_to_adf(&jfm).unwrap();
14243
14244        // Extract the href from the round-tripped ADF
14245        let para = &adf_output.content[0];
14246        let text_node = &para.content.as_ref().unwrap()[0];
14247        let mark = &text_node.marks.as_ref().unwrap()[0];
14248        let result_href = mark.attrs.as_ref().unwrap()["href"].as_str().unwrap();
14249
14250        assert_eq!(result_href, href);
14251    }
14252
14253    #[test]
14254    fn flush_plain_empty_range() {
14255        let mut nodes = Vec::new();
14256        flush_plain("hello", 3, 3, &mut nodes);
14257        assert!(nodes.is_empty());
14258    }
14259
14260    #[test]
14261    fn add_mark_to_unmarked_node() {
14262        let mut node = AdfNode::text("test");
14263        add_mark(&mut node, AdfMark::strong());
14264        assert_eq!(node.marks.as_ref().unwrap().len(), 1);
14265    }
14266
14267    #[test]
14268    fn add_mark_to_marked_node() {
14269        let mut node = AdfNode::text_with_marks("test", vec![AdfMark::strong()]);
14270        add_mark(&mut node, AdfMark::em());
14271        assert_eq!(node.marks.as_ref().unwrap().len(), 2);
14272    }
14273
14274    // ── Directive table tests ──────────────────────────────────────
14275
14276    #[test]
14277    fn directive_table_basic() {
14278        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";
14279        let doc = markdown_to_adf(md).unwrap();
14280        assert_eq!(doc.content[0].node_type, "table");
14281        let rows = doc.content[0].content.as_ref().unwrap();
14282        assert_eq!(rows.len(), 2);
14283        assert_eq!(
14284            rows[0].content.as_ref().unwrap()[0].node_type,
14285            "tableHeader"
14286        );
14287        assert_eq!(rows[1].content.as_ref().unwrap()[0].node_type, "tableCell");
14288    }
14289
14290    #[test]
14291    fn directive_table_with_block_content() {
14292        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";
14293        let doc = markdown_to_adf(md).unwrap();
14294        let rows = doc.content[0].content.as_ref().unwrap();
14295        let cell = &rows[0].content.as_ref().unwrap()[0];
14296        // Cell should have block content (paragraph + bullet list)
14297        let content = cell.content.as_ref().unwrap();
14298        assert!(content.len() >= 2);
14299        assert_eq!(content[1].node_type, "bulletList");
14300    }
14301
14302    #[test]
14303    fn directive_table_with_cell_attrs() {
14304        let md = "::::table\n:::tr\n:::td{colspan=2 bg=#DEEBFF}\nSpanning cell\n:::\n:::\n::::\n";
14305        let doc = markdown_to_adf(md).unwrap();
14306        let cell = &doc.content[0].content.as_ref().unwrap()[0]
14307            .content
14308            .as_ref()
14309            .unwrap()[0];
14310        let attrs = cell.attrs.as_ref().unwrap();
14311        assert_eq!(attrs["colspan"], 2);
14312        assert_eq!(attrs["background"], "#DEEBFF");
14313    }
14314
14315    #[test]
14316    fn directive_table_with_css_var_background() {
14317        let bg = "var(--ds-background-accent-gray-subtlest, var(--ds-background-accent-gray-subtlest, #F1F2F4))";
14318        let md = format!("::::table\n:::tr\n:::th{{bg=\"{bg}\"}}\nHeader\n:::\n:::\n::::\n");
14319        let doc = markdown_to_adf(&md).unwrap();
14320        let row = &doc.content[0].content.as_ref().unwrap()[0];
14321        let cells = row.content.as_ref().unwrap();
14322        assert_eq!(cells.len(), 1, "row must have at least one cell");
14323        let attrs = cells[0].attrs.as_ref().unwrap();
14324        assert_eq!(attrs["background"], bg);
14325    }
14326
14327    #[test]
14328    fn css_var_background_round_trips() {
14329        let bg = "var(--ds-background-accent-gray-subtlest, #F1F2F4)";
14330        let adf = AdfDocument {
14331            version: 1,
14332            doc_type: "doc".to_string(),
14333            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
14334                AdfNode::table_header_with_attrs(
14335                    vec![AdfNode::paragraph(vec![AdfNode::text("Header")])],
14336                    serde_json::json!({"background": bg}),
14337                ),
14338            ])])],
14339        };
14340        let md = adf_to_markdown(&adf).unwrap();
14341        assert!(
14342            md.contains(&format!("bg=\"{bg}\"")),
14343            "bg value must be quoted in markdown: {md}"
14344        );
14345
14346        let round_tripped = markdown_to_adf(&md).unwrap();
14347        let row = &round_tripped.content[0].content.as_ref().unwrap()[0];
14348        let cells = row.content.as_ref().unwrap();
14349        assert_eq!(cells.len(), 1, "round-tripped row must have one cell");
14350        let rt_attrs = cells[0].attrs.as_ref().unwrap();
14351        assert_eq!(rt_attrs["background"], bg);
14352    }
14353
14354    #[test]
14355    fn directive_table_with_table_attrs() {
14356        let md = "::::table{layout=wide numbered}\n:::tr\n:::td\nCell\n:::\n:::\n::::\n";
14357        let doc = markdown_to_adf(md).unwrap();
14358        let attrs = doc.content[0].attrs.as_ref().unwrap();
14359        assert_eq!(attrs["layout"], "wide");
14360        assert_eq!(attrs["isNumberColumnEnabled"], true);
14361    }
14362
14363    #[test]
14364    fn adf_table_with_block_content_renders_directive_form() {
14365        // Table with a bullet list in a cell → should render as ::::table directive
14366        let doc = AdfDocument {
14367            version: 1,
14368            doc_type: "doc".to_string(),
14369            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
14370                AdfNode::table_cell(vec![
14371                    AdfNode::paragraph(vec![AdfNode::text("Cell with list:")]),
14372                    AdfNode::bullet_list(vec![AdfNode::list_item(vec![AdfNode::paragraph(vec![
14373                        AdfNode::text("Item 1"),
14374                    ])])]),
14375                ]),
14376            ])])],
14377        };
14378        let md = adf_to_markdown(&doc).unwrap();
14379        assert!(md.contains("::::table"));
14380        assert!(md.contains(":::td"));
14381        assert!(md.contains("- Item 1"));
14382    }
14383
14384    #[test]
14385    fn adf_table_inline_only_renders_pipe_form() {
14386        // Table with only inline content → pipe table
14387        let doc = AdfDocument {
14388            version: 1,
14389            doc_type: "doc".to_string(),
14390            content: vec![AdfNode::table(vec![
14391                AdfNode::table_row(vec![
14392                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
14393                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
14394                ]),
14395                AdfNode::table_row(vec![
14396                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C1")])]),
14397                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
14398                ]),
14399            ])],
14400        };
14401        let md = adf_to_markdown(&doc).unwrap();
14402        assert!(md.contains("| H1 | H2 |"));
14403        assert!(!md.contains("::::table"));
14404    }
14405
14406    #[test]
14407    fn adf_table_header_outside_first_row_renders_directive() {
14408        let doc = AdfDocument {
14409            version: 1,
14410            doc_type: "doc".to_string(),
14411            content: vec![AdfNode::table(vec![
14412                AdfNode::table_row(vec![
14413                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H")])]),
14414                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C")])]),
14415                ]),
14416                AdfNode::table_row(vec![
14417                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
14418                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
14419                ]),
14420            ])],
14421        };
14422        let md = adf_to_markdown(&doc).unwrap();
14423        assert!(md.contains("::::table"));
14424        assert!(md.contains(":::th"));
14425    }
14426
14427    #[test]
14428    fn adf_table_cell_attrs_rendered() {
14429        let doc = AdfDocument {
14430            version: 1,
14431            doc_type: "doc".to_string(),
14432            content: vec![AdfNode::table(vec![
14433                AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
14434                    AdfNode::text("H"),
14435                ])])]),
14436                AdfNode::table_row(vec![AdfNode::table_cell_with_attrs(
14437                    vec![AdfNode::paragraph(vec![AdfNode::text("C")])],
14438                    serde_json::json!({"background": "#DEEBFF", "colspan": 2}),
14439                )]),
14440            ])],
14441        };
14442        let md = adf_to_markdown(&doc).unwrap();
14443        assert!(md.contains("{colspan=2 bg=#DEEBFF}"));
14444    }
14445
14446    // ── Pipe table cell attrs tests ────────────────────────────────
14447
14448    #[test]
14449    fn pipe_table_cell_attrs() {
14450        let md = "| H1 | H2 |\n|---|---|\n| {bg=#DEEBFF} highlighted | normal |\n";
14451        let doc = markdown_to_adf(md).unwrap();
14452        let rows = doc.content[0].content.as_ref().unwrap();
14453        let cell = &rows[1].content.as_ref().unwrap()[0];
14454        let attrs = cell.attrs.as_ref().unwrap();
14455        assert_eq!(attrs["background"], "#DEEBFF");
14456    }
14457
14458    #[test]
14459    fn pipe_table_cell_colspan() {
14460        let md = "| H1 | H2 |\n|---|---|\n| {colspan=2} spanning |\n";
14461        let doc = markdown_to_adf(md).unwrap();
14462        let rows = doc.content[0].content.as_ref().unwrap();
14463        let cell = &rows[1].content.as_ref().unwrap()[0];
14464        let attrs = cell.attrs.as_ref().unwrap();
14465        assert_eq!(attrs["colspan"], 2);
14466    }
14467
14468    #[test]
14469    fn trailing_space_after_mention_in_table_cell_preserved() {
14470        // Issue #372: trailing space after mention in table cell was dropped
14471        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":[
14472          {"type":"mention","attrs":{"id":"aaa","text":"@Rob"}},
14473          {"type":"text","text":" "}
14474        ]}]}]}]}]}"#;
14475        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14476        let md = adf_to_markdown(&doc).unwrap();
14477        let round_tripped = markdown_to_adf(&md).unwrap();
14478        let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14479            .content
14480            .as_ref()
14481            .unwrap()[0];
14482        let para = &cell.content.as_ref().unwrap()[0];
14483        let inlines = para.content.as_ref().unwrap();
14484        assert!(
14485            inlines.len() >= 2,
14486            "expected mention + text(' ') nodes, got {} nodes: {:?}",
14487            inlines.len(),
14488            inlines.iter().map(|n| &n.node_type).collect::<Vec<_>>()
14489        );
14490        assert_eq!(inlines[0].node_type, "mention");
14491        assert_eq!(inlines[1].node_type, "text");
14492        assert_eq!(inlines[1].text.as_deref(), Some(" "));
14493    }
14494
14495    // ── Column alignment tests ─────────────────────────────────────
14496
14497    #[test]
14498    fn pipe_table_column_alignment() {
14499        let md = "| Left | Center | Right |\n|:---|:---:|---:|\n| L | C | R |\n";
14500        let doc = markdown_to_adf(md).unwrap();
14501        let rows = doc.content[0].content.as_ref().unwrap();
14502        // Header row
14503        let h_cells = rows[0].content.as_ref().unwrap();
14504        // Left → no mark
14505        assert!(h_cells[0].content.as_ref().unwrap()[0].marks.is_none());
14506        // Center → alignment center
14507        let center_marks = h_cells[1].content.as_ref().unwrap()[0]
14508            .marks
14509            .as_ref()
14510            .unwrap();
14511        assert_eq!(center_marks[0].attrs.as_ref().unwrap()["align"], "center");
14512        // Right → alignment end
14513        let right_marks = h_cells[2].content.as_ref().unwrap()[0]
14514            .marks
14515            .as_ref()
14516            .unwrap();
14517        assert_eq!(right_marks[0].attrs.as_ref().unwrap()["align"], "end");
14518    }
14519
14520    #[test]
14521    fn adf_table_alignment_roundtrip() {
14522        let doc = AdfDocument {
14523            version: 1,
14524            doc_type: "doc".to_string(),
14525            content: vec![AdfNode::table(vec![
14526                AdfNode::table_row(vec![
14527                    AdfNode::table_header(vec![{
14528                        let mut p = AdfNode::paragraph(vec![AdfNode::text("Center")]);
14529                        p.marks = Some(vec![AdfMark::alignment("center")]);
14530                        p
14531                    }]),
14532                    AdfNode::table_header(vec![{
14533                        let mut p = AdfNode::paragraph(vec![AdfNode::text("Right")]);
14534                        p.marks = Some(vec![AdfMark::alignment("end")]);
14535                        p
14536                    }]),
14537                ]),
14538                AdfNode::table_row(vec![
14539                    AdfNode::table_cell(vec![{
14540                        let mut p = AdfNode::paragraph(vec![AdfNode::text("C")]);
14541                        p.marks = Some(vec![AdfMark::alignment("center")]);
14542                        p
14543                    }]),
14544                    AdfNode::table_cell(vec![{
14545                        let mut p = AdfNode::paragraph(vec![AdfNode::text("R")]);
14546                        p.marks = Some(vec![AdfMark::alignment("end")]);
14547                        p
14548                    }]),
14549                ]),
14550            ])],
14551        };
14552        let md = adf_to_markdown(&doc).unwrap();
14553        assert!(md.contains(":---:"));
14554        assert!(md.contains("---:"));
14555    }
14556
14557    // ── Panel custom attrs tests ───────────────────────────────────
14558
14559    #[test]
14560    fn panel_custom_attrs_round_trip() {
14561        let md = ":::panel{type=custom icon=\":star:\" color=\"#DEEBFF\"}\nContent\n:::\n";
14562        let doc = markdown_to_adf(md).unwrap();
14563        let panel = &doc.content[0];
14564        let attrs = panel.attrs.as_ref().unwrap();
14565        assert_eq!(attrs["panelType"], "custom");
14566        assert_eq!(attrs["panelIcon"], ":star:");
14567        assert_eq!(attrs["panelColor"], "#DEEBFF");
14568
14569        let result = adf_to_markdown(&doc).unwrap();
14570        assert!(result.contains("type=custom"));
14571        assert!(result.contains("icon="));
14572        assert!(result.contains("color="));
14573    }
14574
14575    // ── Block card with attrs tests ────────────────────────────────
14576
14577    #[test]
14578    fn block_card_with_layout() {
14579        let md = "::card[https://example.com]{layout=wide}\n";
14580        let doc = markdown_to_adf(md).unwrap();
14581        let attrs = doc.content[0].attrs.as_ref().unwrap();
14582        assert_eq!(attrs["layout"], "wide");
14583
14584        let result = adf_to_markdown(&doc).unwrap();
14585        assert!(result.contains("::card[https://example.com]{layout=wide}"));
14586    }
14587
14588    // ── Extension params test ──────────────────────────────────────
14589
14590    #[test]
14591    fn extension_with_params() {
14592        let md = r#"::extension{type=com.atlassian.macro key=jira-chart params='{"jql":"project=PROJ"}'}"#;
14593        let doc = markdown_to_adf(&format!("{md}\n")).unwrap();
14594        let attrs = doc.content[0].attrs.as_ref().unwrap();
14595        assert_eq!(attrs["parameters"]["jql"], "project=PROJ");
14596    }
14597
14598    #[test]
14599    fn leaf_extension_layout_preserved_in_roundtrip() {
14600        // Issue #381: layout attr on extension nodes was dropped
14601        let adf_json = r#"{"version":1,"type":"doc","content":[
14602          {"type":"extension","attrs":{"extensionType":"com.atlassian.confluence.macro.core","extensionKey":"toc","layout":"default","parameters":{}}}
14603        ]}"#;
14604        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14605        let md = adf_to_markdown(&doc).unwrap();
14606        assert!(
14607            md.contains("layout=default"),
14608            "JFM should contain layout=default, got: {md}"
14609        );
14610        let round_tripped = markdown_to_adf(&md).unwrap();
14611        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14612        assert_eq!(attrs["layout"], "default", "layout should be preserved");
14613        assert_eq!(attrs["extensionKey"], "toc");
14614    }
14615
14616    #[test]
14617    fn bodied_extension_layout_preserved_in_roundtrip() {
14618        // Bodied extension with layout
14619        let adf_json = r#"{"version":1,"type":"doc","content":[
14620          {"type":"bodiedExtension","attrs":{"extensionType":"com.atlassian.macro","extensionKey":"expand","layout":"wide"},
14621           "content":[{"type":"paragraph","content":[{"type":"text","text":"inner"}]}]}
14622        ]}"#;
14623        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14624        let md = adf_to_markdown(&doc).unwrap();
14625        assert!(
14626            md.contains("layout=wide"),
14627            "JFM should contain layout=wide, got: {md}"
14628        );
14629        let round_tripped = markdown_to_adf(&md).unwrap();
14630        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14631        assert_eq!(attrs["layout"], "wide", "layout should be preserved");
14632    }
14633
14634    #[test]
14635    fn bodied_extension_parameters_preserved_in_roundtrip() {
14636        // Issue #473: parameters block inside bodiedExtension.attrs was dropped
14637        let adf_json = r#"{"version":1,"type":"doc","content":[
14638          {"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":{}}},
14639           "content":[{"type":"paragraph","content":[{"type":"text","text":"Content inside bodied extension"}]}]}
14640        ]}"#;
14641        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14642        let md = adf_to_markdown(&doc).unwrap();
14643        assert!(
14644            md.contains("params="),
14645            "JFM should contain params attribute, got: {md}"
14646        );
14647        let round_tripped = markdown_to_adf(&md).unwrap();
14648        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14649        assert_eq!(
14650            attrs["parameters"]["macroMetadata"]["title"], "Page Properties",
14651            "parameters should be preserved in round-trip"
14652        );
14653        assert_eq!(attrs["extensionKey"], "details");
14654        assert_eq!(attrs["layout"], "default");
14655        assert_eq!(attrs["localId"], "aabbccdd-1234");
14656    }
14657
14658    #[test]
14659    fn bodied_extension_malformed_params_ignored() {
14660        // Malformed params JSON should be silently ignored, not crash
14661        let md = ":::extension{type=com.atlassian.macro key=details params='not-valid-json'}\nContent\n:::\n";
14662        let doc = markdown_to_adf(md).unwrap();
14663        let attrs = doc.content[0].attrs.as_ref().unwrap();
14664        assert_eq!(attrs["extensionKey"], "details");
14665        // parameters should be absent since the JSON was invalid
14666        assert!(attrs.get("parameters").is_none());
14667    }
14668
14669    #[test]
14670    fn leaf_extension_localid_preserved_in_roundtrip() {
14671        // Extension with both layout and localId
14672        let adf_json = r#"{"version":1,"type":"doc","content":[
14673          {"type":"extension","attrs":{"extensionType":"com.atlassian.macro","extensionKey":"toc","layout":"default","localId":"abc-123"}}
14674        ]}"#;
14675        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14676        let md = adf_to_markdown(&doc).unwrap();
14677        let round_tripped = markdown_to_adf(&md).unwrap();
14678        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14679        assert_eq!(attrs["layout"], "default");
14680        assert_eq!(attrs["localId"], "abc-123");
14681    }
14682
14683    // ── Mention with userType test ─────────────────────────────────
14684
14685    #[test]
14686    fn mention_with_user_type() {
14687        let md = "Hi :mention[Alice]{id=abc123 userType=DEFAULT}.\n";
14688        let doc = markdown_to_adf(md).unwrap();
14689        let mention = &doc.content[0].content.as_ref().unwrap()[1];
14690        assert_eq!(mention.attrs.as_ref().unwrap()["userType"], "DEFAULT");
14691
14692        let result = adf_to_markdown(&doc).unwrap();
14693        assert!(result.contains("userType=DEFAULT"));
14694    }
14695
14696    // ── Colwidth tests ─────────────────────────────────────────────
14697
14698    #[test]
14699    fn directive_table_colwidth() {
14700        let md = "::::table\n:::tr\n:::td{colwidth=100,200}\nCell\n:::\n:::\n::::\n";
14701        let doc = markdown_to_adf(md).unwrap();
14702        let cell = &doc.content[0].content.as_ref().unwrap()[0]
14703            .content
14704            .as_ref()
14705            .unwrap()[0];
14706        let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
14707        assert_eq!(colwidth, &[serde_json::json!(100), serde_json::json!(200)]);
14708    }
14709
14710    #[test]
14711    fn directive_table_colwidth_float_roundtrip() {
14712        // Confluence returns colwidth as floats (e.g. 157.0, 863.0).
14713        // adf_to_markdown must preserve them so markdown_to_adf can restore them.
14714        let adf_doc = serde_json::json!({
14715            "type": "doc",
14716            "version": 1,
14717            "content": [{
14718                "type": "table",
14719                "content": [{
14720                    "type": "tableRow",
14721                    "content": [
14722                        {
14723                            "type": "tableHeader",
14724                            "attrs": { "colwidth": [157.0] },
14725                            "content": [{ "type": "paragraph" }]
14726                        },
14727                        {
14728                            "type": "tableHeader",
14729                            "attrs": { "colwidth": [863.0] },
14730                            "content": [{ "type": "paragraph" }]
14731                        }
14732                    ]
14733                }]
14734            }]
14735        });
14736        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
14737        let md = adf_to_markdown(&doc).unwrap();
14738        assert!(
14739            md.contains("colwidth=157.0"),
14740            "expected colwidth=157.0 in markdown, got: {md}"
14741        );
14742        assert!(
14743            md.contains("colwidth=863.0"),
14744            "expected colwidth=863.0 in markdown, got: {md}"
14745        );
14746        // Round-trip back to ADF
14747        let doc2 = markdown_to_adf(&md).unwrap();
14748        let row = &doc2.content[0].content.as_ref().unwrap()[0];
14749        let header1 = &row.content.as_ref().unwrap()[0];
14750        let header2 = &row.content.as_ref().unwrap()[1];
14751        assert_eq!(
14752            header1.attrs.as_ref().unwrap()["colwidth"]
14753                .as_array()
14754                .unwrap(),
14755            &[serde_json::json!(157.0)]
14756        );
14757        assert_eq!(
14758            header2.attrs.as_ref().unwrap()["colwidth"]
14759                .as_array()
14760                .unwrap(),
14761            &[serde_json::json!(863.0)]
14762        );
14763    }
14764
14765    #[test]
14766    fn colwidth_float_preserved_in_roundtrip() {
14767        // Issue #369: colwidth 254.0 was coerced to integer 254
14768        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":[]}]}]}]}]}"#;
14769        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14770        let md = adf_to_markdown(&doc).unwrap();
14771        let round_tripped = markdown_to_adf(&md).unwrap();
14772        let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14773            .content
14774            .as_ref()
14775            .unwrap()[0];
14776        let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
14777        assert_eq!(
14778            colwidth,
14779            &[serde_json::json!(254.0), serde_json::json!(416.0)],
14780            "colwidth should preserve float values"
14781        );
14782    }
14783
14784    #[test]
14785    fn colwidth_integer_preserved_in_roundtrip() {
14786        // Issue #459: colwidth integer values emitted as floats after round-trip
14787        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"}]}]}]}]}]}"#;
14788        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14789        let md = adf_to_markdown(&doc).unwrap();
14790        assert!(
14791            md.contains("colwidth=150"),
14792            "expected colwidth=150 (no decimal) in markdown, got: {md}"
14793        );
14794        assert!(
14795            !md.contains("colwidth=150.0"),
14796            "colwidth should not have .0 suffix for integers, got: {md}"
14797        );
14798        // Round-trip back to ADF
14799        let round_tripped = markdown_to_adf(&md).unwrap();
14800        let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14801            .content
14802            .as_ref()
14803            .unwrap()[0];
14804        let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
14805        assert_eq!(
14806            colwidth,
14807            &[serde_json::json!(150)],
14808            "colwidth should preserve integer values"
14809        );
14810        // Verify JSON serialization uses integer, not float
14811        let json_output = serde_json::to_string(&round_tripped).unwrap();
14812        assert!(
14813            json_output.contains(r#""colwidth":[150]"#),
14814            "JSON should contain integer colwidth, got: {json_output}"
14815        );
14816    }
14817
14818    #[test]
14819    fn colwidth_mixed_int_and_float_roundtrip() {
14820        // Integer colwidth from standard ADF and float colwidth from Confluence
14821        // should each preserve their original type through round-trip.
14822        let int_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colwidth":[100,200]}}]}]}]}"#;
14823        let float_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colwidth":[100.0,200.0]}}]}]}]}"#;
14824
14825        // Integer input → integer output
14826        let int_doc: AdfDocument = serde_json::from_str(int_json).unwrap();
14827        let int_md = adf_to_markdown(&int_doc).unwrap();
14828        assert!(
14829            int_md.contains("colwidth=100,200"),
14830            "integer colwidth in md: {int_md}"
14831        );
14832        let int_rt = markdown_to_adf(&int_md).unwrap();
14833        let int_serial = serde_json::to_string(&int_rt).unwrap();
14834        assert!(
14835            int_serial.contains(r#""colwidth":[100,200]"#),
14836            "integer colwidth in JSON: {int_serial}"
14837        );
14838
14839        // Float input → float output
14840        let float_doc: AdfDocument = serde_json::from_str(float_json).unwrap();
14841        let float_md = adf_to_markdown(&float_doc).unwrap();
14842        assert!(
14843            float_md.contains("colwidth=100.0,200.0"),
14844            "float colwidth in md: {float_md}"
14845        );
14846        let float_rt = markdown_to_adf(&float_md).unwrap();
14847        let float_serial = serde_json::to_string(&float_rt).unwrap();
14848        assert!(
14849            float_serial.contains(r#""colwidth":[100.0,200.0]"#),
14850            "float colwidth in JSON: {float_serial}"
14851        );
14852    }
14853
14854    #[test]
14855    fn colwidth_fractional_float_preserved() {
14856        // Covers the fractional-float branch (n.fract() != 0.0) in build_cell_attrs_string
14857        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"}]}]}]}]}]}"#;
14858        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14859        let md = adf_to_markdown(&doc).unwrap();
14860        assert!(
14861            md.contains("colwidth=100.5"),
14862            "expected colwidth=100.5 in markdown, got: {md}"
14863        );
14864    }
14865
14866    #[test]
14867    fn colwidth_non_numeric_values_skipped() {
14868        // Covers the None branch for non-numeric colwidth entries in build_cell_attrs_string
14869        let adf_doc = serde_json::json!({
14870            "type": "doc",
14871            "version": 1,
14872            "content": [{
14873                "type": "table",
14874                "content": [{
14875                    "type": "tableRow",
14876                    "content": [{
14877                        "type": "tableCell",
14878                        "attrs": { "colwidth": ["invalid"] },
14879                        "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "cell" }] }]
14880                    }]
14881                }]
14882            }]
14883        });
14884        let doc: AdfDocument = serde_json::from_value(adf_doc).unwrap();
14885        let md = adf_to_markdown(&doc).unwrap();
14886        // Non-numeric values are filtered out, so colwidth should not appear
14887        assert!(
14888            !md.contains("colwidth"),
14889            "non-numeric colwidth should be filtered out, got: {md}"
14890        );
14891    }
14892
14893    #[test]
14894    fn default_rowspan_colspan_preserved_in_roundtrip() {
14895        // Issue #369: rowspan=1 and colspan=1 were 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":{"rowspan":1,"colspan":1},"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 cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14901            .content
14902            .as_ref()
14903            .unwrap()[0];
14904        let attrs = cell.attrs.as_ref().unwrap();
14905        assert_eq!(attrs["rowspan"], 1, "rowspan=1 should be preserved");
14906        assert_eq!(attrs["colspan"], 1, "colspan=1 should be preserved");
14907    }
14908
14909    // ── Nested list tests ──────────────────────────────────────────────
14910
14911    #[test]
14912    fn table_localid_preserved_in_roundtrip() {
14913        // Issue #374: localId on table nodes was dropped
14914        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"}]}]}]}]}]}"#;
14915        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14916        let md = adf_to_markdown(&doc).unwrap();
14917        assert!(
14918            md.contains("localId="),
14919            "JFM should contain localId, got: {md}"
14920        );
14921        let round_tripped = markdown_to_adf(&md).unwrap();
14922        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14923        assert_eq!(
14924            attrs["localId"], "7afd4550-e66c-4b12-875f-a91c6c7b62c7",
14925            "localId should be preserved"
14926        );
14927    }
14928
14929    #[test]
14930    fn paragraph_localid_preserved_in_roundtrip() {
14931        // Issue #399: localId on paragraph nodes was dropped
14932        let adf_json = r#"{"version":1,"type":"doc","content":[
14933          {"type":"paragraph","attrs":{"localId":"abc-123"},"content":[{"type":"text","text":"hello"}]}
14934        ]}"#;
14935        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14936        let md = adf_to_markdown(&doc).unwrap();
14937        assert!(
14938            md.contains("localId=abc-123"),
14939            "JFM should contain localId, got: {md}"
14940        );
14941        let round_tripped = markdown_to_adf(&md).unwrap();
14942        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14943        assert_eq!(attrs["localId"], "abc-123", "localId should be preserved");
14944    }
14945
14946    #[test]
14947    fn heading_localid_preserved_in_roundtrip() {
14948        let adf_json = r#"{"version":1,"type":"doc","content":[
14949          {"type":"heading","attrs":{"level":2,"localId":"h-456"},"content":[{"type":"text","text":"Title"}]}
14950        ]}"#;
14951        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14952        let md = adf_to_markdown(&doc).unwrap();
14953        let round_tripped = markdown_to_adf(&md).unwrap();
14954        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14955        assert_eq!(attrs["localId"], "h-456");
14956    }
14957
14958    #[test]
14959    fn localid_with_alignment_preserved() {
14960        // localId and alignment marks should coexist in the same {attrs} block
14961        let adf_json = r#"{"version":1,"type":"doc","content":[
14962          {"type":"paragraph","attrs":{"localId":"p-789"},"marks":[{"type":"alignment","attrs":{"align":"center"}}],
14963           "content":[{"type":"text","text":"centered"}]}
14964        ]}"#;
14965        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14966        let md = adf_to_markdown(&doc).unwrap();
14967        assert!(md.contains("localId=p-789"), "should have localId: {md}");
14968        assert!(md.contains("align=center"), "should have align: {md}");
14969        let round_tripped = markdown_to_adf(&md).unwrap();
14970        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14971        assert_eq!(attrs["localId"], "p-789");
14972        let marks = round_tripped.content[0].marks.as_ref().unwrap();
14973        assert!(marks.iter().any(|m| m.mark_type == "alignment"));
14974    }
14975
14976    #[test]
14977    fn table_layout_default_preserved_in_roundtrip() {
14978        // Issue #380: layout='default' was elided
14979        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"}]}]}]}]}]}"#;
14980        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14981        let md = adf_to_markdown(&doc).unwrap();
14982        let round_tripped = markdown_to_adf(&md).unwrap();
14983        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14984        assert_eq!(
14985            attrs["layout"], "default",
14986            "layout='default' should be preserved"
14987        );
14988    }
14989
14990    #[test]
14991    fn table_is_number_column_enabled_false_preserved() {
14992        // Issue #380: isNumberColumnEnabled=false was elided
14993        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"}]}]}]}]}]}"#;
14994        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14995        let md = adf_to_markdown(&doc).unwrap();
14996        let round_tripped = markdown_to_adf(&md).unwrap();
14997        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14998        assert_eq!(
14999            attrs["isNumberColumnEnabled"], false,
15000            "isNumberColumnEnabled=false should be preserved"
15001        );
15002    }
15003
15004    #[test]
15005    fn table_is_number_column_enabled_true_preserved() {
15006        // Regression check: isNumberColumnEnabled=true should still work
15007        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"}]}]}]}]}]}"#;
15008        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15009        let md = adf_to_markdown(&doc).unwrap();
15010        let round_tripped = markdown_to_adf(&md).unwrap();
15011        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15012        assert_eq!(
15013            attrs["isNumberColumnEnabled"], true,
15014            "isNumberColumnEnabled=true should be preserved"
15015        );
15016    }
15017
15018    #[test]
15019    fn directive_table_is_number_column_enabled_false_preserved() {
15020        // Covers render_directive_table + directive table parsing for numbered=false.
15021        // Multi-paragraph cell forces directive table form.
15022        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[
15023          {"type":"paragraph","content":[{"type":"text","text":"line one"}]},
15024          {"type":"paragraph","content":[{"type":"text","text":"line two"}]}
15025        ]}]}]}]}"#;
15026        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15027        let md = adf_to_markdown(&doc).unwrap();
15028        assert!(md.contains("::::table"), "should use directive table form");
15029        assert!(
15030            md.contains("numbered=false"),
15031            "should contain numbered=false, got: {md}"
15032        );
15033        let round_tripped = markdown_to_adf(&md).unwrap();
15034        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15035        assert_eq!(attrs["isNumberColumnEnabled"], false);
15036        assert_eq!(attrs["layout"], "default");
15037    }
15038
15039    #[test]
15040    fn directive_table_is_number_column_enabled_true_preserved() {
15041        // Covers render_directive_table + directive table parsing for numbered (true).
15042        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":true,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[
15043          {"type":"paragraph","content":[{"type":"text","text":"line one"}]},
15044          {"type":"paragraph","content":[{"type":"text","text":"line two"}]}
15045        ]}]}]}]}"#;
15046        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15047        let md = adf_to_markdown(&doc).unwrap();
15048        assert!(md.contains("::::table"), "should use directive table form");
15049        assert!(
15050            md.contains("numbered}") || md.contains("numbered "),
15051            "should contain numbered flag, got: {md}"
15052        );
15053        let round_tripped = markdown_to_adf(&md).unwrap();
15054        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15055        assert_eq!(attrs["isNumberColumnEnabled"], true);
15056    }
15057
15058    #[test]
15059    fn trailing_space_in_bullet_list_item_preserved() {
15060        // Issue #394: trailing space text node in list item dropped
15061        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
15062          {"type":"listItem","content":[{"type":"paragraph","content":[
15063            {"type":"text","text":"Before link "},
15064            {"type":"text","text":"link text","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
15065            {"type":"text","text":" "}
15066          ]}]}
15067        ]}]}"#;
15068        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15069        let md = adf_to_markdown(&doc).unwrap();
15070        let round_tripped = markdown_to_adf(&md).unwrap();
15071        let list = &round_tripped.content[0];
15072        let item = &list.content.as_ref().unwrap()[0];
15073        let para = &item.content.as_ref().unwrap()[0];
15074        let inlines = para.content.as_ref().unwrap();
15075        let last = inlines.last().unwrap();
15076        assert_eq!(
15077            last.text.as_deref(),
15078            Some(" "),
15079            "trailing space text node should be preserved, got nodes: {:?}",
15080            inlines
15081                .iter()
15082                .map(|n| (&n.node_type, &n.text))
15083                .collect::<Vec<_>>()
15084        );
15085    }
15086
15087    #[test]
15088    fn trailing_space_after_mention_in_bullet_list_preserved() {
15089        // Mention + trailing space in list item
15090        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
15091          {"type":"listItem","content":[{"type":"paragraph","content":[
15092            {"type":"mention","attrs":{"id":"abc","text":"@Alice"}},
15093            {"type":"text","text":" "}
15094          ]}]}
15095        ]}]}"#;
15096        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15097        let md = adf_to_markdown(&doc).unwrap();
15098        let round_tripped = markdown_to_adf(&md).unwrap();
15099        let para = &round_tripped.content[0].content.as_ref().unwrap()[0]
15100            .content
15101            .as_ref()
15102            .unwrap()[0];
15103        let inlines = para.content.as_ref().unwrap();
15104        assert!(
15105            inlines.len() >= 2,
15106            "should have mention + trailing space, got {} nodes",
15107            inlines.len()
15108        );
15109        assert_eq!(inlines.last().unwrap().text.as_deref(), Some(" "));
15110    }
15111
15112    #[test]
15113    fn trailing_space_in_ordered_list_item_preserved() {
15114        // Same issue in ordered list context
15115        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
15116          {"type":"listItem","content":[{"type":"paragraph","content":[
15117            {"type":"text","text":"item "},
15118            {"type":"text","text":"link","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
15119            {"type":"text","text":" "}
15120          ]}]}
15121        ]}]}"#;
15122        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15123        let md = adf_to_markdown(&doc).unwrap();
15124        let round_tripped = markdown_to_adf(&md).unwrap();
15125        let para = &round_tripped.content[0].content.as_ref().unwrap()[0]
15126            .content
15127            .as_ref()
15128            .unwrap()[0];
15129        let inlines = para.content.as_ref().unwrap();
15130        let last = inlines.last().unwrap();
15131        assert_eq!(
15132            last.text.as_deref(),
15133            Some(" "),
15134            "trailing space should be preserved in ordered list item"
15135        );
15136    }
15137
15138    #[test]
15139    fn trailing_space_in_heading_text_preserved() {
15140        // Issue #400: trailing space in heading text node trimmed on round-trip
15141        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[
15142          {"type":"text","text":"Firefighting Engineers "}
15143        ]}]}"#;
15144        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15145        let md = adf_to_markdown(&doc).unwrap();
15146        let round_tripped = markdown_to_adf(&md).unwrap();
15147        let inlines = round_tripped.content[0].content.as_ref().unwrap();
15148        assert_eq!(
15149            inlines[0].text.as_deref(),
15150            Some("Firefighting Engineers "),
15151            "trailing space in heading should be preserved"
15152        );
15153    }
15154
15155    #[test]
15156    fn trailing_space_in_heading_before_bold_preserved() {
15157        // Issue #400: trailing space before bold sibling in heading
15158        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[
15159          {"type":"text","text":"Classic "},
15160          {"type":"text","text":"bold","marks":[{"type":"strong"}]}
15161        ]}]}"#;
15162        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15163        let md = adf_to_markdown(&doc).unwrap();
15164        let round_tripped = markdown_to_adf(&md).unwrap();
15165        let inlines = round_tripped.content[0].content.as_ref().unwrap();
15166        assert_eq!(
15167            inlines[0].text.as_deref(),
15168            Some("Classic "),
15169            "trailing space in heading text before bold should be preserved"
15170        );
15171    }
15172
15173    #[test]
15174    fn leading_space_in_heading_text_preserved() {
15175        // Issue #492: leading spaces in heading text node stripped on round-trip
15176        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":3},"content":[
15177          {"type":"text","text":"  #general-channel"}
15178        ]}]}"#;
15179        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15180        let md = adf_to_markdown(&doc).unwrap();
15181        let round_tripped = markdown_to_adf(&md).unwrap();
15182        let inlines = round_tripped.content[0].content.as_ref().unwrap();
15183        assert_eq!(
15184            inlines[0].text.as_deref(),
15185            Some("  #general-channel"),
15186            "leading spaces in heading text should be preserved"
15187        );
15188    }
15189
15190    #[test]
15191    fn leading_space_in_heading_before_bold_preserved() {
15192        // Issue #492: leading space before bold sibling in heading
15193        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[
15194          {"type":"text","text":"   indented"},
15195          {"type":"text","text":" bold","marks":[{"type":"strong"}]}
15196        ]}]}"#;
15197        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15198        let md = adf_to_markdown(&doc).unwrap();
15199        let round_tripped = markdown_to_adf(&md).unwrap();
15200        let inlines = round_tripped.content[0].content.as_ref().unwrap();
15201        assert_eq!(
15202            inlines[0].text.as_deref(),
15203            Some("   indented"),
15204            "leading spaces in heading text before bold should be preserved"
15205        );
15206    }
15207
15208    #[test]
15209    fn heading_multiple_leading_spaces_markdown_parse() {
15210        // Issue #492: verify JFM parsing preserves leading spaces
15211        let md = "### \t  #general-channel";
15212        let doc = markdown_to_adf(md).unwrap();
15213        let inlines = doc.content[0].content.as_ref().unwrap();
15214        assert_eq!(
15215            inlines[0].text.as_deref(),
15216            Some("\t  #general-channel"),
15217            "leading whitespace in heading text should be preserved during JFM parsing"
15218        );
15219    }
15220
15221    #[test]
15222    fn trailing_space_in_paragraph_text_preserved() {
15223        // Issue #400: trailing space in paragraph text node preserved on round-trip
15224        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
15225          {"type":"text","text":"word followed by space "},
15226          {"type":"text","text":"next node","marks":[{"type":"strong"}]}
15227        ]}]}"#;
15228        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15229        let md = adf_to_markdown(&doc).unwrap();
15230        let round_tripped = markdown_to_adf(&md).unwrap();
15231        let inlines = round_tripped.content[0].content.as_ref().unwrap();
15232        assert_eq!(
15233            inlines[0].text.as_deref(),
15234            Some("word followed by space "),
15235            "trailing space in paragraph text should be preserved"
15236        );
15237    }
15238
15239    #[test]
15240    fn nested_bullet_list_roundtrip() {
15241        // ADF with a listItem containing a paragraph + nested bulletList
15242        let adf_doc = serde_json::json!({
15243            "type": "doc",
15244            "version": 1,
15245            "content": [{
15246                "type": "bulletList",
15247                "content": [{
15248                    "type": "listItem",
15249                    "content": [
15250                        {
15251                            "type": "paragraph",
15252                            "content": [{"type": "text", "text": "parent item"}]
15253                        },
15254                        {
15255                            "type": "bulletList",
15256                            "content": [
15257                                {
15258                                    "type": "listItem",
15259                                    "content": [{
15260                                        "type": "paragraph",
15261                                        "content": [{"type": "text", "text": "sub item 1"}]
15262                                    }]
15263                                },
15264                                {
15265                                    "type": "listItem",
15266                                    "content": [{
15267                                        "type": "paragraph",
15268                                        "content": [{"type": "text", "text": "sub item 2"}]
15269                                    }]
15270                                }
15271                            ]
15272                        }
15273                    ]
15274                }]
15275            }]
15276        });
15277        let doc: AdfDocument = serde_json::from_value(adf_doc).unwrap();
15278        let md = adf_to_markdown(&doc).unwrap();
15279        assert!(
15280            md.contains("- parent item\n"),
15281            "expected top-level item in markdown, got: {md}"
15282        );
15283        assert!(
15284            md.contains("  - sub item 1\n"),
15285            "expected indented sub item 1 in markdown, got: {md}"
15286        );
15287        assert!(
15288            md.contains("  - sub item 2\n"),
15289            "expected indented sub item 2 in markdown, got: {md}"
15290        );
15291
15292        // Round-trip back
15293        let doc2 = markdown_to_adf(&md).unwrap();
15294        let list = &doc2.content[0];
15295        assert_eq!(list.node_type, "bulletList");
15296        let item = &list.content.as_ref().unwrap()[0];
15297        assert_eq!(item.node_type, "listItem");
15298        let item_content = item.content.as_ref().unwrap();
15299        assert_eq!(
15300            item_content.len(),
15301            2,
15302            "listItem should have paragraph + nested list"
15303        );
15304        assert_eq!(item_content[0].node_type, "paragraph");
15305        assert_eq!(item_content[1].node_type, "bulletList");
15306        let sub_items = item_content[1].content.as_ref().unwrap();
15307        assert_eq!(sub_items.len(), 2);
15308    }
15309
15310    #[test]
15311    fn nested_bullet_in_table_cell_roundtrip() {
15312        let md = "::::table\n:::tr\n:::td\n- parent\n  - child\n:::\n:::\n::::\n";
15313        let doc = markdown_to_adf(md).unwrap();
15314        let table = &doc.content[0];
15315        let row = &table.content.as_ref().unwrap()[0];
15316        let cell = &row.content.as_ref().unwrap()[0];
15317        let list = &cell.content.as_ref().unwrap()[0];
15318        assert_eq!(list.node_type, "bulletList");
15319        let item = &list.content.as_ref().unwrap()[0];
15320        let item_content = item.content.as_ref().unwrap();
15321        assert_eq!(
15322            item_content.len(),
15323            2,
15324            "listItem should have paragraph + nested list"
15325        );
15326        assert_eq!(item_content[1].node_type, "bulletList");
15327
15328        // Round-trip: adf→md→adf should preserve the nested list
15329        let md2 = adf_to_markdown(&doc).unwrap();
15330        assert!(
15331            md2.contains("  - child"),
15332            "expected indented child in round-tripped markdown, got: {md2}"
15333        );
15334    }
15335
15336    #[test]
15337    fn nested_ordered_list_roundtrip() {
15338        // Issue #389: nested orderedList inside listItem flattened
15339        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
15340          {"type":"listItem","content":[
15341            {"type":"paragraph","content":[{"type":"text","text":"Top level"}]},
15342            {"type":"orderedList","attrs":{"order":1},"content":[
15343              {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Nested 1"}]}]},
15344              {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Nested 2"}]}]}
15345            ]}
15346          ]},
15347          {"type":"listItem","content":[
15348            {"type":"paragraph","content":[{"type":"text","text":"Second top"}]}
15349          ]}
15350        ]}]}"#;
15351        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15352        let md = adf_to_markdown(&doc).unwrap();
15353        let round_tripped = markdown_to_adf(&md).unwrap();
15354
15355        // Outer list should have 2 items
15356        let outer = &round_tripped.content[0];
15357        assert_eq!(outer.node_type, "orderedList");
15358        assert_eq!(
15359            outer.attrs.as_ref().unwrap()["order"],
15360            1,
15361            "explicit order=1 must be preserved via trailing {{order=1}} (issue #547)"
15362        );
15363        let outer_items = outer.content.as_ref().unwrap();
15364        assert_eq!(
15365            outer_items.len(),
15366            2,
15367            "outer list should have 2 items, got {}",
15368            outer_items.len()
15369        );
15370
15371        // First item should have paragraph + nested orderedList
15372        let first_item = &outer_items[0];
15373        let first_content = first_item.content.as_ref().unwrap();
15374        assert_eq!(
15375            first_content.len(),
15376            2,
15377            "first listItem should have paragraph + nested list, got {}",
15378            first_content.len()
15379        );
15380        assert_eq!(first_content[0].node_type, "paragraph");
15381        assert_eq!(first_content[1].node_type, "orderedList");
15382        let nested_items = first_content[1].content.as_ref().unwrap();
15383        assert_eq!(nested_items.len(), 2, "nested list should have 2 items");
15384    }
15385
15386    #[test]
15387    fn nested_ordered_list_markdown_parsing() {
15388        // Direct markdown parsing of nested ordered list
15389        let md = "1. Top level\n  1. Nested 1\n  2. Nested 2\n2. Second top\n";
15390        let doc = markdown_to_adf(md).unwrap();
15391        let outer = &doc.content[0];
15392        assert_eq!(outer.node_type, "orderedList");
15393        let outer_items = outer.content.as_ref().unwrap();
15394        assert_eq!(outer_items.len(), 2, "should have 2 top-level items");
15395
15396        let first_content = outer_items[0].content.as_ref().unwrap();
15397        assert_eq!(
15398            first_content.len(),
15399            2,
15400            "first item should have paragraph + nested list"
15401        );
15402        assert_eq!(first_content[1].node_type, "orderedList");
15403    }
15404
15405    #[test]
15406    fn bullet_list_nested_inside_ordered_list() {
15407        // Mixed nesting: bullet list nested inside ordered list
15408        let md = "1. Ordered item\n  - Bullet child 1\n  - Bullet child 2\n2. Second ordered\n";
15409        let doc = markdown_to_adf(md).unwrap();
15410        let outer = &doc.content[0];
15411        assert_eq!(outer.node_type, "orderedList");
15412        let outer_items = outer.content.as_ref().unwrap();
15413        assert_eq!(outer_items.len(), 2);
15414
15415        let first_content = outer_items[0].content.as_ref().unwrap();
15416        assert_eq!(
15417            first_content.len(),
15418            2,
15419            "first item should have paragraph + nested list"
15420        );
15421        assert_eq!(first_content[1].node_type, "bulletList");
15422        let sub_items = first_content[1].content.as_ref().unwrap();
15423        assert_eq!(sub_items.len(), 2, "nested bullet list should have 2 items");
15424    }
15425
15426    #[test]
15427    fn ordered_list_order_attr_one_is_elided() {
15428        // Issue #547: order=1 is the default and must be elided from attrs
15429        // for round-trip fidelity with ADF documents that omit the attrs
15430        // object on orderedList.
15431        let md = "1. A\n2. B\n";
15432        let doc = markdown_to_adf(md).unwrap();
15433        assert!(
15434            doc.content[0].attrs.is_none(),
15435            "attrs should be elided when order=1"
15436        );
15437
15438        // Round-trip should preserve the elision
15439        let md2 = adf_to_markdown(&doc).unwrap();
15440        let doc2 = markdown_to_adf(&md2).unwrap();
15441        assert!(
15442            doc2.content[0].attrs.is_none(),
15443            "attrs should remain elided after round-trip"
15444        );
15445    }
15446
15447    #[test]
15448    fn issue_547_ordered_list_no_attrs_roundtrip_byte_identical() {
15449        // Issue #547: ADF orderedList without an attrs field must round-trip
15450        // (ADF → JFM → ADF) without gaining a spurious {"order": 1} attrs.
15451        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"}]}]}]}]}"#;
15452        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15453        let md = adf_to_markdown(&doc).unwrap();
15454        let rt = markdown_to_adf(&md).unwrap();
15455        assert!(
15456            rt.content[0].attrs.is_none(),
15457            "round-tripped orderedList should not have attrs, got: {:?}",
15458            rt.content[0].attrs
15459        );
15460
15461        // Serialized JSON must also omit attrs entirely for byte fidelity.
15462        let rt_json = serde_json::to_string(&rt).unwrap();
15463        assert!(
15464            !rt_json.contains("\"order\""),
15465            "round-tripped JSON should not contain \"order\", got: {rt_json}"
15466        );
15467    }
15468
15469    // ── Issue #547: orderedList byte-identical roundtrip coverage ───────
15470
15471    /// Assert that ADF → JFM → ADF produces a document whose serialized JSON
15472    /// (as a sorted-key canonical form) matches the source JSON. Mirrors the
15473    /// `jq --sort-keys` comparison used in the issue's reproducer.
15474    fn assert_roundtrip_byte_identical(adf_json: &str) {
15475        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15476        let md = adf_to_markdown(&doc).unwrap();
15477        let rt = markdown_to_adf(&md).unwrap();
15478
15479        let canonical_src: serde_json::Value = serde_json::from_str(adf_json).unwrap();
15480        let canonical_rt: serde_json::Value =
15481            serde_json::from_str(&serde_json::to_string(&rt).unwrap()).unwrap();
15482        assert_eq!(
15483            canonical_src, canonical_rt,
15484            "round-trip diverged\n  src: {canonical_src}\n   rt: {canonical_rt}\n   md: {md:?}"
15485        );
15486    }
15487
15488    #[test]
15489    fn issue_547_single_item_no_attrs_roundtrip() {
15490        assert_roundtrip_byte_identical(
15491            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"only"}]}]}]}]}"#,
15492        );
15493    }
15494
15495    #[test]
15496    fn issue_547_many_items_no_attrs_roundtrip() {
15497        assert_roundtrip_byte_identical(
15498            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"}]}]}]}]}"#,
15499        );
15500    }
15501
15502    #[test]
15503    fn issue_547_non_default_order_preserved() {
15504        // When order != 1, attrs must still be serialized (fix must not
15505        // over-eagerly drop attrs).
15506        assert_roundtrip_byte_identical(
15507            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":5},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"fifth"}]}]}]}]}"#,
15508        );
15509    }
15510
15511    #[test]
15512    fn issue_547_nested_ordered_in_ordered_no_attrs_roundtrip() {
15513        // Outer and inner both omit attrs; fix must apply at every level.
15514        assert_roundtrip_byte_identical(
15515            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"}]}]}]}]}]}]}"#,
15516        );
15517    }
15518
15519    #[test]
15520    fn issue_547_ordered_nested_in_bullet_no_attrs_roundtrip() {
15521        assert_roundtrip_byte_identical(
15522            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"}]}]}]}]}]}]}"#,
15523        );
15524    }
15525
15526    #[test]
15527    fn issue_547_bullet_nested_in_ordered_no_attrs_roundtrip() {
15528        assert_roundtrip_byte_identical(
15529            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"}]}]}]}]}]}]}"#,
15530        );
15531    }
15532
15533    #[test]
15534    fn issue_547_ordered_list_between_paragraphs_roundtrip() {
15535        assert_roundtrip_byte_identical(
15536            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"}]}]}"#,
15537        );
15538    }
15539
15540    #[test]
15541    fn issue_547_ordered_list_with_marked_text_roundtrip() {
15542        assert_roundtrip_byte_identical(
15543            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"bold","marks":[{"type":"strong"}]}]}]}]}]}"#,
15544        );
15545    }
15546
15547    #[test]
15548    fn issue_547_ordered_list_with_link_roundtrip() {
15549        assert_roundtrip_byte_identical(
15550            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"}}]}]}]}]}]}"#,
15551        );
15552    }
15553
15554    #[test]
15555    fn issue_547_ordered_list_with_hardbreak_roundtrip() {
15556        assert_roundtrip_byte_identical(
15557            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"}]}]}]}]}"#,
15558        );
15559    }
15560
15561    #[test]
15562    fn issue_547_triple_nested_ordered_roundtrip() {
15563        assert_roundtrip_byte_identical(
15564            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"}]}]}]}]}]}]}]}]}"#,
15565        );
15566    }
15567
15568    #[test]
15569    fn issue_547_ordered_list_heading_rule_mix_roundtrip() {
15570        assert_roundtrip_byte_identical(
15571            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"}]}"#,
15572        );
15573    }
15574
15575    #[test]
15576    fn issue_547_ordered_list_listitem_localid_roundtrip() {
15577        // listItem attrs must coexist with the no-attrs outer orderedList.
15578        assert_roundtrip_byte_identical(
15579            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","attrs":{"localId":"li-001"},"content":[{"type":"paragraph","content":[{"type":"text","text":"first"}]}]}]}]}"#,
15580        );
15581    }
15582
15583    #[test]
15584    fn issue_547_explicit_order_one_preserved_roundtrip() {
15585        // Inverse regression (see PR #562 comment 4266630848): when the source
15586        // ADF has an explicit `"attrs": {"order": 1}` the round-trip must
15587        // preserve it, not strip it. A trailing `{order=1}` signal on the
15588        // rendered markdown distinguishes explicit-default from omitted attrs.
15589        assert_roundtrip_byte_identical(
15590            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"First item"}]}]}]}]}"#,
15591        );
15592    }
15593
15594    #[test]
15595    fn issue_547_explicit_order_one_nested_preserved_roundtrip() {
15596        // Both outer and inner orderedList have explicit `order: 1`; both must
15597        // be preserved across the round-trip independently.
15598        assert_roundtrip_byte_identical(
15599            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"}]}]}]}]}]}]}"#,
15600        );
15601    }
15602
15603    #[test]
15604    fn issue_547_mixed_explicit_and_implicit_order_roundtrip() {
15605        // Sibling orderedLists with different attrs presence must round-trip
15606        // independently: first has explicit `order: 1`, second omits attrs.
15607        assert_roundtrip_byte_identical(
15608            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"}]}]}]}]}"#,
15609        );
15610    }
15611
15612    #[test]
15613    fn issue_547_explicit_order_one_with_listitem_localid_roundtrip() {
15614        // Explicit `order: 1` outer, plus a listItem `localId` inside — the
15615        // trailing `{order=1}` line must not swallow or collide with listItem
15616        // attrs.
15617        assert_roundtrip_byte_identical(
15618            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"}]}]}]}]}"#,
15619        );
15620    }
15621
15622    #[test]
15623    fn issue_547_order_attr_signal_appears_only_for_explicit_one() {
15624        // Render-layer guard: `{order=1}` appears in markdown only when the
15625        // source ADF has explicit `attrs.order=1`. No signal for attrs=None,
15626        // no signal for attrs.order>1 (marker already encodes the value).
15627        let no_attrs = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]}]}"#;
15628        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"}]}]}]}]}"#;
15629        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"}]}]}]}]}"#;
15630
15631        let md_no =
15632            adf_to_markdown(&serde_json::from_str::<AdfDocument>(no_attrs).unwrap()).unwrap();
15633        let md_one =
15634            adf_to_markdown(&serde_json::from_str::<AdfDocument>(explicit_one).unwrap()).unwrap();
15635        let md_five =
15636            adf_to_markdown(&serde_json::from_str::<AdfDocument>(order_five).unwrap()).unwrap();
15637
15638        assert!(
15639            !md_no.contains("{order="),
15640            "no-attrs source must not emit order signal, got: {md_no:?}"
15641        );
15642        assert!(
15643            md_one.contains("{order=1}"),
15644            "explicit order=1 must emit trailing signal, got: {md_one:?}"
15645        );
15646        assert!(
15647            !md_five.contains("{order="),
15648            "order=5 is already encoded by marker; must not emit signal, got: {md_five:?}"
15649        );
15650    }
15651
15652    // ── File media round-trip tests ─────────────────────────────────────
15653
15654    #[test]
15655    fn file_media_roundtrip() {
15656        // ADF with a Confluence file attachment (type:file media)
15657        let adf_doc = serde_json::json!({
15658            "type": "doc",
15659            "version": 1,
15660            "content": [{
15661                "type": "mediaSingle",
15662                "attrs": {"layout": "center"},
15663                "content": [{
15664                    "type": "media",
15665                    "attrs": {
15666                        "type": "file",
15667                        "id": "6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d",
15668                        "collection": "contentId-8220672100",
15669                        "height": 56,
15670                        "width": 312,
15671                        "alt": "Screenshot.png"
15672                    }
15673                }]
15674            }]
15675        });
15676        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
15677        let md = adf_to_markdown(&doc).unwrap();
15678        assert!(
15679            md.contains("type=file"),
15680            "expected type=file in markdown, got: {md}"
15681        );
15682        assert!(
15683            md.contains("id=6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d"),
15684            "expected id in markdown, got: {md}"
15685        );
15686        assert!(
15687            md.contains("collection=contentId-8220672100"),
15688            "expected collection in markdown, got: {md}"
15689        );
15690        // Round-trip back to ADF
15691        let doc2 = markdown_to_adf(&md).unwrap();
15692        let ms = &doc2.content[0];
15693        assert_eq!(ms.node_type, "mediaSingle");
15694        let media = &ms.content.as_ref().unwrap()[0];
15695        assert_eq!(media.node_type, "media");
15696        let attrs = media.attrs.as_ref().unwrap();
15697        assert_eq!(attrs["type"], "file");
15698        assert_eq!(attrs["id"], "6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d");
15699        assert_eq!(attrs["collection"], "contentId-8220672100");
15700        assert_eq!(attrs["height"], 56);
15701        assert_eq!(attrs["width"], 312);
15702        assert_eq!(attrs["alt"], "Screenshot.png");
15703    }
15704
15705    /// Issue #550: roundtrip of mediaSingle with file-type media preserves all
15706    /// file attributes (type, id, collection, width, height). Regression guard
15707    /// for the exact reproducer in the issue body.
15708    #[test]
15709    fn file_media_roundtrip_issue_550_reproducer() {
15710        let adf_json = r#"{
15711          "version": 1,
15712          "type": "doc",
15713          "content": [
15714            {
15715              "type": "mediaSingle",
15716              "attrs": {"layout": "center"},
15717              "content": [
15718                {
15719                  "type": "media",
15720                  "attrs": {
15721                    "type": "file",
15722                    "id": "abc-123-def-456",
15723                    "collection": "my-collection",
15724                    "width": 941,
15725                    "height": 655
15726                  }
15727                }
15728              ]
15729            }
15730          ]
15731        }"#;
15732        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15733        let md = adf_to_markdown(&doc).unwrap();
15734        let rt = markdown_to_adf(&md).unwrap();
15735        let expected: serde_json::Value = serde_json::from_str(adf_json).unwrap();
15736        let actual = serde_json::to_value(&rt).unwrap();
15737        assert_eq!(
15738            actual, expected,
15739            "roundtrip should preserve file media attrs; md was:\n{md}"
15740        );
15741    }
15742
15743    /// Issue #550 (updated reproducer): roundtrip of a file-media `id`
15744    /// containing spaces must not truncate the value. Before the fix, the
15745    /// JFM renderer emitted `id=abc 123 def 456` unquoted and the parser
15746    /// treated the first space as a value terminator, so the `id` became
15747    /// `"abc"` after round-trip.
15748    #[test]
15749    fn file_media_roundtrip_id_with_spaces() {
15750        let adf_json = r#"{
15751          "version": 1,
15752          "type": "doc",
15753          "content": [
15754            {
15755              "type": "mediaSingle",
15756              "attrs": {"layout": "center"},
15757              "content": [
15758                {
15759                  "type": "media",
15760                  "attrs": {
15761                    "type": "file",
15762                    "id": "abc 123 def 456",
15763                    "collection": "my-collection",
15764                    "width": 800,
15765                    "height": 600
15766                  }
15767                }
15768              ]
15769            }
15770          ]
15771        }"#;
15772        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15773        let md = adf_to_markdown(&doc).unwrap();
15774        assert!(
15775            md.contains(r#"id="abc 123 def 456""#),
15776            "id with spaces should be quoted in JFM, got:\n{md}"
15777        );
15778        let rt = markdown_to_adf(&md).unwrap();
15779        let expected: serde_json::Value = serde_json::from_str(adf_json).unwrap();
15780        let actual = serde_json::to_value(&rt).unwrap();
15781        assert_eq!(
15782            actual, expected,
15783            "space-containing id must round-trip; md was:\n{md}"
15784        );
15785    }
15786
15787    /// Space-containing `collection` values must round-trip.
15788    #[test]
15789    fn file_media_roundtrip_collection_with_spaces() {
15790        let adf_json = r#"{
15791          "version": 1,
15792          "type": "doc",
15793          "content": [
15794            {
15795              "type": "mediaSingle",
15796              "attrs": {"layout": "center"},
15797              "content": [
15798                {
15799                  "type": "media",
15800                  "attrs": {
15801                    "type": "file",
15802                    "id": "abc-123",
15803                    "collection": "my collection with spaces"
15804                  }
15805                }
15806              ]
15807            }
15808          ]
15809        }"#;
15810        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15811        let md = adf_to_markdown(&doc).unwrap();
15812        let rt = markdown_to_adf(&md).unwrap();
15813        let media = &rt.content[0].content.as_ref().unwrap()[0];
15814        assert_eq!(
15815            media.attrs.as_ref().unwrap()["collection"],
15816            "my collection with spaces"
15817        );
15818    }
15819
15820    /// Space-containing `occurrenceKey` values must round-trip.
15821    #[test]
15822    fn file_media_roundtrip_occurrence_key_with_spaces() {
15823        let adf_json = r#"{
15824          "version": 1,
15825          "type": "doc",
15826          "content": [
15827            {
15828              "type": "mediaSingle",
15829              "attrs": {"layout": "center"},
15830              "content": [
15831                {
15832                  "type": "media",
15833                  "attrs": {
15834                    "type": "file",
15835                    "id": "x",
15836                    "collection": "y",
15837                    "occurrenceKey": "key with spaces"
15838                  }
15839                }
15840              ]
15841            }
15842          ]
15843        }"#;
15844        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15845        let md = adf_to_markdown(&doc).unwrap();
15846        let rt = markdown_to_adf(&md).unwrap();
15847        let media = &rt.content[0].content.as_ref().unwrap()[0];
15848        assert_eq!(
15849            media.attrs.as_ref().unwrap()["occurrenceKey"],
15850            "key with spaces"
15851        );
15852    }
15853
15854    /// Values with embedded `"` must be escape-quoted and round-trip.
15855    #[test]
15856    fn file_media_roundtrip_id_with_quote_char() {
15857        let adf_json = r#"{
15858          "version": 1,
15859          "type": "doc",
15860          "content": [
15861            {
15862              "type": "mediaSingle",
15863              "attrs": {"layout": "center"},
15864              "content": [
15865                {
15866                  "type": "media",
15867                  "attrs": {
15868                    "type": "file",
15869                    "id": "a\"b\"c",
15870                    "collection": "col"
15871                  }
15872                }
15873              ]
15874            }
15875          ]
15876        }"#;
15877        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15878        let md = adf_to_markdown(&doc).unwrap();
15879        let rt = markdown_to_adf(&md).unwrap();
15880        let media = &rt.content[0].content.as_ref().unwrap()[0];
15881        assert_eq!(media.attrs.as_ref().unwrap()["id"], "a\"b\"c");
15882    }
15883
15884    /// `mediaInline` string attrs with spaces must round-trip (parallel fix
15885    /// for the inline-directive rendering path).
15886    #[test]
15887    fn media_inline_roundtrip_id_with_spaces() {
15888        let adf_json = r#"{
15889          "version": 1,
15890          "type": "doc",
15891          "content": [
15892            {
15893              "type": "paragraph",
15894              "content": [
15895                {"type": "text", "text": "before "},
15896                {
15897                  "type": "mediaInline",
15898                  "attrs": {
15899                    "type": "file",
15900                    "id": "a b c",
15901                    "collection": "my col"
15902                  }
15903                },
15904                {"type": "text", "text": " after"}
15905              ]
15906            }
15907          ]
15908        }"#;
15909        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15910        let md = adf_to_markdown(&doc).unwrap();
15911        let rt = markdown_to_adf(&md).unwrap();
15912        let inline = &rt.content[0].content.as_ref().unwrap()[1];
15913        assert_eq!(inline.node_type, "mediaInline");
15914        let attrs = inline.attrs.as_ref().unwrap();
15915        assert_eq!(attrs["id"], "a b c");
15916        assert_eq!(attrs["collection"], "my col");
15917    }
15918
15919    /// Issue #550: `occurrenceKey` attribute is a standard ADF media attr and
15920    /// must be preserved through ADF→JFM→ADF roundtrip.
15921    #[test]
15922    fn file_media_roundtrip_preserves_occurrence_key() {
15923        let adf_json = r#"{
15924          "version": 1,
15925          "type": "doc",
15926          "content": [
15927            {
15928              "type": "mediaSingle",
15929              "attrs": {"layout": "center"},
15930              "content": [
15931                {
15932                  "type": "media",
15933                  "attrs": {
15934                    "type": "file",
15935                    "id": "abc-123",
15936                    "collection": "my-collection",
15937                    "occurrenceKey": "unique-key-xyz",
15938                    "width": 200,
15939                    "height": 100
15940                  }
15941                }
15942              ]
15943            }
15944          ]
15945        }"#;
15946        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15947        let md = adf_to_markdown(&doc).unwrap();
15948        assert!(
15949            md.contains("occurrenceKey=unique-key-xyz"),
15950            "expected occurrenceKey in markdown, got: {md}"
15951        );
15952        let rt = markdown_to_adf(&md).unwrap();
15953        let media = &rt.content[0].content.as_ref().unwrap()[0];
15954        let attrs = media.attrs.as_ref().unwrap();
15955        assert_eq!(attrs["occurrenceKey"], "unique-key-xyz");
15956        assert_eq!(attrs["type"], "file");
15957        assert_eq!(attrs["id"], "abc-123");
15958        assert_eq!(attrs["collection"], "my-collection");
15959    }
15960
15961    // ── mediaSingle caption tests (issue #470) ──────────────────────────
15962
15963    #[test]
15964    fn media_single_caption_adf_to_markdown() {
15965        let adf_doc = serde_json::json!({
15966            "type": "doc",
15967            "version": 1,
15968            "content": [{
15969                "type": "mediaSingle",
15970                "attrs": {"layout": "center", "width": 400, "widthType": "pixel"},
15971                "content": [
15972                    {
15973                        "type": "media",
15974                        "attrs": {
15975                            "id": "aabbccdd-1234-5678-abcd-aabbccdd1234",
15976                            "type": "file",
15977                            "collection": "contentId-123456",
15978                            "width": 800,
15979                            "height": 600
15980                        }
15981                    },
15982                    {
15983                        "type": "caption",
15984                        "content": [{"type": "text", "text": "An image caption here"}]
15985                    }
15986                ]
15987            }]
15988        });
15989        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
15990        let md = adf_to_markdown(&doc).unwrap();
15991        assert!(
15992            md.contains(":::caption"),
15993            "expected :::caption in markdown, got: {md}"
15994        );
15995        assert!(
15996            md.contains("An image caption here"),
15997            "expected caption text in markdown, got: {md}"
15998        );
15999    }
16000
16001    #[test]
16002    fn media_single_caption_markdown_to_adf() {
16003        let md = "![Screenshot](){type=file id=abc-123 collection=contentId-456 height=600 width=800}\n:::caption\nAn image caption here\n:::\n";
16004        let doc = markdown_to_adf(md).unwrap();
16005        let ms = &doc.content[0];
16006        assert_eq!(ms.node_type, "mediaSingle");
16007        let content = ms.content.as_ref().unwrap();
16008        assert_eq!(content.len(), 2, "expected media + caption children");
16009        assert_eq!(content[0].node_type, "media");
16010        assert_eq!(content[1].node_type, "caption");
16011        let caption_content = content[1].content.as_ref().unwrap();
16012        assert_eq!(
16013            caption_content[0].text.as_deref(),
16014            Some("An image caption here")
16015        );
16016    }
16017
16018    #[test]
16019    fn media_single_caption_round_trip() {
16020        // Full round-trip: ADF → JFM → ADF preserves caption
16021        let adf_doc = serde_json::json!({
16022            "type": "doc",
16023            "version": 1,
16024            "content": [{
16025                "type": "mediaSingle",
16026                "attrs": {"layout": "center", "width": 400, "widthType": "pixel"},
16027                "content": [
16028                    {
16029                        "type": "media",
16030                        "attrs": {
16031                            "id": "aabbccdd-1234-5678-abcd-aabbccdd1234",
16032                            "type": "file",
16033                            "collection": "contentId-123456",
16034                            "width": 800,
16035                            "height": 600
16036                        }
16037                    },
16038                    {
16039                        "type": "caption",
16040                        "content": [{"type": "text", "text": "An image caption here"}]
16041                    }
16042                ]
16043            }]
16044        });
16045        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16046        let md = adf_to_markdown(&doc).unwrap();
16047        let doc2 = markdown_to_adf(&md).unwrap();
16048        let ms = &doc2.content[0];
16049        assert_eq!(ms.node_type, "mediaSingle");
16050        let content = ms.content.as_ref().unwrap();
16051        assert_eq!(
16052            content.len(),
16053            2,
16054            "expected media + caption after round-trip"
16055        );
16056        assert_eq!(content[1].node_type, "caption");
16057        let caption_content = content[1].content.as_ref().unwrap();
16058        assert_eq!(
16059            caption_content[0].text.as_deref(),
16060            Some("An image caption here")
16061        );
16062    }
16063
16064    #[test]
16065    fn media_single_caption_with_inline_marks() {
16066        let adf_doc = serde_json::json!({
16067            "type": "doc",
16068            "version": 1,
16069            "content": [{
16070                "type": "mediaSingle",
16071                "attrs": {"layout": "center"},
16072                "content": [
16073                    {
16074                        "type": "media",
16075                        "attrs": {"type": "external", "url": "https://example.com/img.png"}
16076                    },
16077                    {
16078                        "type": "caption",
16079                        "content": [
16080                            {"type": "text", "text": "A "},
16081                            {"type": "text", "text": "bold", "marks": [{"type": "strong"}]},
16082                            {"type": "text", "text": " caption"}
16083                        ]
16084                    }
16085                ]
16086            }]
16087        });
16088        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16089        let md = adf_to_markdown(&doc).unwrap();
16090        assert!(
16091            md.contains("**bold**"),
16092            "expected bold in caption, got: {md}"
16093        );
16094
16095        let doc2 = markdown_to_adf(&md).unwrap();
16096        let content = doc2.content[0].content.as_ref().unwrap();
16097        assert_eq!(content.len(), 2, "expected media + caption");
16098        assert_eq!(content[1].node_type, "caption");
16099        let caption_inlines = content[1].content.as_ref().unwrap();
16100        let bold_node = caption_inlines
16101            .iter()
16102            .find(|n| n.text.as_deref() == Some("bold"))
16103            .unwrap();
16104        let marks = bold_node.marks.as_ref().unwrap();
16105        assert_eq!(marks[0].mark_type, "strong");
16106    }
16107
16108    #[test]
16109    fn media_single_no_caption_unaffected() {
16110        // Existing mediaSingle without caption should be unaffected
16111        let adf_doc = serde_json::json!({
16112            "type": "doc",
16113            "version": 1,
16114            "content": [{
16115                "type": "mediaSingle",
16116                "attrs": {"layout": "center"},
16117                "content": [{
16118                    "type": "media",
16119                    "attrs": {"type": "external", "url": "https://example.com/img.png"}
16120                }]
16121            }]
16122        });
16123        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16124        let md = adf_to_markdown(&doc).unwrap();
16125        assert!(
16126            !md.contains(":::caption"),
16127            "should not emit caption when none present"
16128        );
16129        let doc2 = markdown_to_adf(&md).unwrap();
16130        let content = doc2.content[0].content.as_ref().unwrap();
16131        assert_eq!(content.len(), 1, "should only have media child");
16132        assert_eq!(content[0].node_type, "media");
16133    }
16134
16135    #[test]
16136    fn media_single_empty_caption_round_trip() {
16137        // Caption node with no content should still round-trip
16138        let adf_doc = serde_json::json!({
16139            "type": "doc",
16140            "version": 1,
16141            "content": [{
16142                "type": "mediaSingle",
16143                "attrs": {"layout": "center"},
16144                "content": [
16145                    {
16146                        "type": "media",
16147                        "attrs": {"type": "external", "url": "https://example.com/img.png"}
16148                    },
16149                    {
16150                        "type": "caption"
16151                    }
16152                ]
16153            }]
16154        });
16155        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16156        let md = adf_to_markdown(&doc).unwrap();
16157        assert!(
16158            md.contains(":::caption"),
16159            "expected :::caption even for empty caption, got: {md}"
16160        );
16161        assert!(
16162            md.contains(":::\n"),
16163            "expected closing ::: fence, got: {md}"
16164        );
16165    }
16166
16167    #[test]
16168    fn media_single_external_caption_round_trip() {
16169        // External image with caption round-trips
16170        let md = "![alt](https://example.com/img.png)\n:::caption\nImage description\n:::\n";
16171        let doc = markdown_to_adf(md).unwrap();
16172        let ms = &doc.content[0];
16173        assert_eq!(ms.node_type, "mediaSingle");
16174        let content = ms.content.as_ref().unwrap();
16175        assert_eq!(content.len(), 2);
16176        assert_eq!(content[0].node_type, "media");
16177        assert_eq!(content[1].node_type, "caption");
16178
16179        let md2 = adf_to_markdown(&doc).unwrap();
16180        let doc2 = markdown_to_adf(&md2).unwrap();
16181        let content2 = doc2.content[0].content.as_ref().unwrap();
16182        assert_eq!(content2.len(), 2);
16183        assert_eq!(content2[1].node_type, "caption");
16184        let caption_text = content2[1].content.as_ref().unwrap();
16185        assert_eq!(caption_text[0].text.as_deref(), Some("Image description"));
16186    }
16187
16188    // ── mediaSingle caption localId tests (issue #524) ─────────────────
16189
16190    #[test]
16191    fn media_single_caption_localid_roundtrip() {
16192        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"}]}]}]}"#;
16193        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16194        let md = adf_to_markdown(&doc).unwrap();
16195        assert!(
16196            md.contains("localId=9da8c2104471"),
16197            "caption localId should appear in markdown: {md}"
16198        );
16199        let rt = markdown_to_adf(&md).unwrap();
16200        let content = rt.content[0].content.as_ref().unwrap();
16201        let caption = &content[1];
16202        assert_eq!(caption.node_type, "caption");
16203        assert_eq!(
16204            caption.attrs.as_ref().unwrap()["localId"],
16205            "9da8c2104471",
16206            "caption localId should round-trip"
16207        );
16208    }
16209
16210    #[test]
16211    fn media_single_caption_without_localid() {
16212        let md = "![Screenshot](){type=file id=abc-123 collection=contentId-456 height=600 width=800}\n:::caption\nPlain caption\n:::\n";
16213        let doc = markdown_to_adf(md).unwrap();
16214        let caption = &doc.content[0].content.as_ref().unwrap()[1];
16215        assert_eq!(caption.node_type, "caption");
16216        assert!(
16217            caption.attrs.is_none(),
16218            "caption without localId should not gain attrs"
16219        );
16220        let md2 = adf_to_markdown(&doc).unwrap();
16221        assert!(
16222            !md2.contains("localId"),
16223            "no localId should appear in output: {md2}"
16224        );
16225    }
16226
16227    #[test]
16228    fn media_single_caption_localid_stripped_when_option_set() {
16229        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"}]}]}]}"#;
16230        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16231        let opts = RenderOptions {
16232            strip_local_ids: true,
16233            ..Default::default()
16234        };
16235        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
16236        assert!(!md.contains("localId"), "localId should be stripped: {md}");
16237    }
16238
16239    #[test]
16240    fn table_width_roundtrip() {
16241        // ADF table with width attribute
16242        let adf_doc = serde_json::json!({
16243            "type": "doc",
16244            "version": 1,
16245            "content": [{
16246                "type": "table",
16247                "attrs": {"layout": "default", "width": 760.0},
16248                "content": [{
16249                    "type": "tableRow",
16250                    "content": [{
16251                        "type": "tableHeader",
16252                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "H"}]}]
16253                    }]
16254                }]
16255            }]
16256        });
16257        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16258        let md = adf_to_markdown(&doc).unwrap();
16259        assert!(
16260            md.contains("width=760.0"),
16261            "expected width=760.0 in markdown (float preserved), got: {md}"
16262        );
16263        // Round-trip back to ADF
16264        let doc2 = markdown_to_adf(&md).unwrap();
16265        let table = &doc2.content[0];
16266        assert_eq!(table.node_type, "table");
16267        let table_attrs = table.attrs.as_ref().unwrap();
16268        assert_eq!(table_attrs["width"], 760.0);
16269        assert!(
16270            table_attrs["width"].is_f64(),
16271            "expected float width to be preserved as f64, got: {:?}",
16272            table_attrs["width"]
16273        );
16274    }
16275
16276    #[test]
16277    fn table_integer_width_roundtrip_preserves_integer() {
16278        // Issue #577: Integer width in ADF must survive roundtrip without being
16279        // coerced to a float.
16280        let adf_doc = serde_json::json!({
16281            "type": "doc",
16282            "version": 1,
16283            "content": [{
16284                "type": "table",
16285                "attrs": {
16286                    "isNumberColumnEnabled": false,
16287                    "layout": "center",
16288                    "localId": "abc-123",
16289                    "width": 1420
16290                },
16291                "content": [{
16292                    "type": "tableRow",
16293                    "content": [{
16294                        "type": "tableCell",
16295                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Cell"}]}]
16296                    }]
16297                }]
16298            }]
16299        });
16300        let doc: crate::atlassian::adf::AdfDocument =
16301            serde_json::from_value(adf_doc.clone()).unwrap();
16302        let md = adf_to_markdown(&doc).unwrap();
16303        assert!(
16304            md.contains("width=1420"),
16305            "expected width=1420 in markdown, got: {md}"
16306        );
16307        assert!(
16308            !md.contains("width=1420.0"),
16309            "integer width should not be rendered with decimal: {md}"
16310        );
16311
16312        let doc2 = markdown_to_adf(&md).unwrap();
16313        let table = &doc2.content[0];
16314        assert_eq!(table.node_type, "table");
16315        let table_attrs = table.attrs.as_ref().unwrap();
16316        assert_eq!(table_attrs["width"], 1420);
16317        assert!(
16318            table_attrs["width"].is_u64() || table_attrs["width"].is_i64(),
16319            "width should remain an integer, got: {:?}",
16320            table_attrs["width"]
16321        );
16322        assert!(
16323            !table_attrs["width"].is_f64(),
16324            "width should not be a float, got: {:?}",
16325            table_attrs["width"]
16326        );
16327
16328        // Full byte-fidelity: re-serialized ADF should match original JSON.
16329        let roundtripped = serde_json::to_value(&doc2).unwrap();
16330        let orig_width = &adf_doc["content"][0]["attrs"]["width"];
16331        let rt_width = &roundtripped["content"][0]["attrs"]["width"];
16332        assert_eq!(
16333            orig_width, rt_width,
16334            "width value must roundtrip byte-for-byte"
16335        );
16336    }
16337
16338    #[test]
16339    fn table_fractional_width_roundtrip() {
16340        // Fractional float widths should also roundtrip faithfully.
16341        let adf_doc = serde_json::json!({
16342            "type": "doc",
16343            "version": 1,
16344            "content": [{
16345                "type": "table",
16346                "attrs": {"layout": "default", "width": 760.5},
16347                "content": [{
16348                    "type": "tableRow",
16349                    "content": [{
16350                        "type": "tableHeader",
16351                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "H"}]}]
16352                    }]
16353                }]
16354            }]
16355        });
16356        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16357        let md = adf_to_markdown(&doc).unwrap();
16358        assert!(
16359            md.contains("width=760.5"),
16360            "expected width=760.5 in markdown, got: {md}"
16361        );
16362        let doc2 = markdown_to_adf(&md).unwrap();
16363        let table_attrs = doc2.content[0].attrs.as_ref().unwrap();
16364        assert_eq!(table_attrs["width"], 760.5);
16365        assert!(table_attrs["width"].is_f64());
16366    }
16367
16368    #[test]
16369    fn pipe_table_integer_width_roundtrip() {
16370        // Exercises the try_table() attrs-on-next-line parsing path.
16371        let md = "| A | B |\n|---|---|\n| 1 | 2 |\n{layout=default width=1420}\n";
16372        let doc = markdown_to_adf(md).unwrap();
16373        let table = &doc.content[0];
16374        assert_eq!(table.node_type, "table");
16375        let attrs = table.attrs.as_ref().unwrap();
16376        assert_eq!(attrs["width"], 1420);
16377        assert!(
16378            attrs["width"].is_u64() || attrs["width"].is_i64(),
16379            "pipe-table width must stay integer, got: {:?}",
16380            attrs["width"]
16381        );
16382    }
16383
16384    #[test]
16385    fn file_media_width_type_roundtrip() {
16386        // mediaSingle with widthType:pixel should survive round-trip
16387        let adf_doc = serde_json::json!({
16388            "type": "doc",
16389            "version": 1,
16390            "content": [{
16391                "type": "mediaSingle",
16392                "attrs": {"layout": "center", "width": 312, "widthType": "pixel"},
16393                "content": [{
16394                    "type": "media",
16395                    "attrs": {
16396                        "type": "file",
16397                        "id": "abc123",
16398                        "collection": "contentId-999",
16399                        "height": 56,
16400                        "width": 312
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("widthType=pixel"),
16409            "expected widthType=pixel 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["widthType"], "pixel");
16415        assert_eq!(ms_attrs["width"], 312);
16416    }
16417
16418    #[test]
16419    fn file_media_pixel_width_above_100_passes_validation() {
16420        // Issue #1037: confluence_read emits pixel widths well above 100 for
16421        // editor-sized images (here width=900). Lowering carries width=900
16422        // widthType=pixel onto the mediaSingle, which is valid ADF — the
16423        // upstream schema's pixel branch is unbounded. A read→write round-trip
16424        // must clear the write-path validator, not be rejected as out of range
16425        // (the original failure: "value 900 is outside the allowed range
16426        // [0, 100]"). Unlike `file_media_width_type_roundtrip`, this asserts on
16427        // the validator, which the conversion-only round-trip never exercises.
16428        let md = "![alt](){type=file id=11111111-2222-3333-4444-555555555555 collection=contentId-98765 height=904 width=900 mediaWidth=900 widthType=pixel}\n";
16429        let adf = markdown_to_adf(md).unwrap();
16430
16431        let ms_attrs = adf.content[0].attrs.as_ref().unwrap();
16432        assert_eq!(ms_attrs["width"], 900);
16433        assert_eq!(ms_attrs["widthType"], "pixel");
16434
16435        crate::atlassian::adf_validated::validate(&adf).unwrap();
16436    }
16437
16438    #[test]
16439    fn file_media_mode_roundtrip() {
16440        // mediaSingle with mode attr should survive round-trip (issue #431)
16441        let adf_doc = serde_json::json!({
16442            "type": "doc",
16443            "version": 1,
16444            "content": [{
16445                "type": "mediaSingle",
16446                "attrs": {"layout": "wide", "mode": "wide", "width": 1200},
16447                "content": [{
16448                    "type": "media",
16449                    "attrs": {
16450                        "type": "file",
16451                        "id": "abc123",
16452                        "collection": "test",
16453                        "width": 1200,
16454                        "height": 600
16455                    }
16456                }]
16457            }]
16458        });
16459        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16460        let md = adf_to_markdown(&doc).unwrap();
16461        assert!(
16462            md.contains("mode=wide"),
16463            "expected mode=wide in markdown, got: {md}"
16464        );
16465        let doc2 = markdown_to_adf(&md).unwrap();
16466        let ms = &doc2.content[0];
16467        let ms_attrs = ms.attrs.as_ref().unwrap();
16468        assert_eq!(ms_attrs["mode"], "wide");
16469        assert_eq!(ms_attrs["layout"], "wide");
16470        assert_eq!(ms_attrs["width"], 1200);
16471    }
16472
16473    #[test]
16474    fn external_media_mode_roundtrip() {
16475        // External mediaSingle with mode attr should survive round-trip (issue #431)
16476        let adf_doc = serde_json::json!({
16477            "type": "doc",
16478            "version": 1,
16479            "content": [{
16480                "type": "mediaSingle",
16481                "attrs": {"layout": "wide", "mode": "wide"},
16482                "content": [{
16483                    "type": "media",
16484                    "attrs": {
16485                        "type": "external",
16486                        "url": "https://example.com/image.png"
16487                    }
16488                }]
16489            }]
16490        });
16491        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16492        let md = adf_to_markdown(&doc).unwrap();
16493        assert!(
16494            md.contains("mode=wide"),
16495            "expected mode=wide in markdown, got: {md}"
16496        );
16497        let doc2 = markdown_to_adf(&md).unwrap();
16498        let ms = &doc2.content[0];
16499        let ms_attrs = ms.attrs.as_ref().unwrap();
16500        assert_eq!(ms_attrs["mode"], "wide");
16501        assert_eq!(ms_attrs["layout"], "wide");
16502    }
16503
16504    #[test]
16505    fn media_mode_only_roundtrip() {
16506        // mediaSingle with mode but default layout should still preserve mode (issue #431)
16507        let adf_doc = serde_json::json!({
16508            "type": "doc",
16509            "version": 1,
16510            "content": [{
16511                "type": "mediaSingle",
16512                "attrs": {"layout": "center", "mode": "default"},
16513                "content": [{
16514                    "type": "media",
16515                    "attrs": {
16516                        "type": "external",
16517                        "url": "https://example.com/image.png"
16518                    }
16519                }]
16520            }]
16521        });
16522        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16523        let md = adf_to_markdown(&doc).unwrap();
16524        assert!(
16525            md.contains("mode=default"),
16526            "expected mode=default in markdown, got: {md}"
16527        );
16528        let doc2 = markdown_to_adf(&md).unwrap();
16529        let ms = &doc2.content[0];
16530        let ms_attrs = ms.attrs.as_ref().unwrap();
16531        assert_eq!(ms_attrs["mode"], "default");
16532    }
16533
16534    #[test]
16535    fn file_media_hex_localid_roundtrip() {
16536        // Issue #432: short hex localId (non-UUID) must survive round-trip
16537        let adf_doc = serde_json::json!({
16538            "type": "doc",
16539            "version": 1,
16540            "content": [{
16541                "type": "mediaSingle",
16542                "attrs": {"layout": "wide", "width": 1200, "widthType": "pixel"},
16543                "content": [{
16544                    "type": "media",
16545                    "attrs": {
16546                        "type": "file",
16547                        "id": "eb7a9c3b-314e-4458-8200-4b22b67b122e",
16548                        "collection": "contentId-123",
16549                        "height": 484,
16550                        "width": 915,
16551                        "alt": "image.png",
16552                        "localId": "0e79f58ac382"
16553                    }
16554                }]
16555            }]
16556        });
16557        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16558        let md = adf_to_markdown(&doc).unwrap();
16559        assert!(
16560            md.contains("localId=0e79f58ac382"),
16561            "expected localId=0e79f58ac382 in markdown, got: {md}"
16562        );
16563        let doc2 = markdown_to_adf(&md).unwrap();
16564        let ms = &doc2.content[0];
16565        let media = &ms.content.as_ref().unwrap()[0];
16566        let attrs = media.attrs.as_ref().unwrap();
16567        assert_eq!(attrs["localId"], "0e79f58ac382");
16568    }
16569
16570    #[test]
16571    fn file_media_uuid_localid_roundtrip() {
16572        // UUID-format localId must also survive round-trip
16573        let adf_doc = serde_json::json!({
16574            "type": "doc",
16575            "version": 1,
16576            "content": [{
16577                "type": "mediaSingle",
16578                "attrs": {"layout": "center"},
16579                "content": [{
16580                    "type": "media",
16581                    "attrs": {
16582                        "type": "file",
16583                        "id": "abc-123",
16584                        "collection": "contentId-456",
16585                        "height": 100,
16586                        "width": 200,
16587                        "localId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
16588                    }
16589                }]
16590            }]
16591        });
16592        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16593        let md = adf_to_markdown(&doc).unwrap();
16594        assert!(
16595            md.contains("localId=a1b2c3d4-e5f6-7890-abcd-ef1234567890"),
16596            "expected UUID localId in markdown, got: {md}"
16597        );
16598        let doc2 = markdown_to_adf(&md).unwrap();
16599        let media = &doc2.content[0].content.as_ref().unwrap()[0];
16600        let attrs = media.attrs.as_ref().unwrap();
16601        assert_eq!(attrs["localId"], "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
16602    }
16603
16604    #[test]
16605    fn file_media_null_uuid_localid_stripped() {
16606        // Null UUID localId should be stripped (consistent with other node types)
16607        let adf_doc = serde_json::json!({
16608            "type": "doc",
16609            "version": 1,
16610            "content": [{
16611                "type": "mediaSingle",
16612                "attrs": {"layout": "center"},
16613                "content": [{
16614                    "type": "media",
16615                    "attrs": {
16616                        "type": "file",
16617                        "id": "abc-123",
16618                        "collection": "contentId-456",
16619                        "height": 100,
16620                        "width": 200,
16621                        "localId": "00000000-0000-0000-0000-000000000000"
16622                    }
16623                }]
16624            }]
16625        });
16626        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16627        let md = adf_to_markdown(&doc).unwrap();
16628        assert!(
16629            !md.contains("localId="),
16630            "null UUID localId should be stripped, got: {md}"
16631        );
16632    }
16633
16634    #[test]
16635    fn file_media_localid_stripped_when_option_set() {
16636        // localId should be stripped when strip_local_ids option is enabled
16637        let adf_doc = serde_json::json!({
16638            "type": "doc",
16639            "version": 1,
16640            "content": [{
16641                "type": "mediaSingle",
16642                "attrs": {"layout": "center"},
16643                "content": [{
16644                    "type": "media",
16645                    "attrs": {
16646                        "type": "file",
16647                        "id": "abc-123",
16648                        "collection": "contentId-456",
16649                        "height": 100,
16650                        "width": 200,
16651                        "localId": "0e79f58ac382"
16652                    }
16653                }]
16654            }]
16655        });
16656        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16657        let opts = RenderOptions {
16658            strip_local_ids: true,
16659            ..Default::default()
16660        };
16661        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
16662        assert!(
16663            !md.contains("localId="),
16664            "localId should be stripped with strip_local_ids, got: {md}"
16665        );
16666    }
16667
16668    #[test]
16669    fn external_media_localid_roundtrip() {
16670        // localId on external media nodes must also survive round-trip
16671        let adf_doc = serde_json::json!({
16672            "type": "doc",
16673            "version": 1,
16674            "content": [{
16675                "type": "mediaSingle",
16676                "attrs": {"layout": "center"},
16677                "content": [{
16678                    "type": "media",
16679                    "attrs": {
16680                        "type": "external",
16681                        "url": "https://example.com/image.png",
16682                        "alt": "test",
16683                        "localId": "deadbeef1234"
16684                    }
16685                }]
16686            }]
16687        });
16688        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16689        let md = adf_to_markdown(&doc).unwrap();
16690        assert!(
16691            md.contains("localId=deadbeef1234"),
16692            "expected localId in markdown for external media, got: {md}"
16693        );
16694        let doc2 = markdown_to_adf(&md).unwrap();
16695        let media = &doc2.content[0].content.as_ref().unwrap()[0];
16696        let attrs = media.attrs.as_ref().unwrap();
16697        assert_eq!(attrs["localId"], "deadbeef1234");
16698    }
16699
16700    #[test]
16701    fn bracket_in_text_not_parsed_as_link() {
16702        // "[Task] some text (Link)" — the [Task] must NOT be treated as a link anchor
16703        let md = ":check_mark: [Task] Unable to start trial ([Link](https://example.com/link))";
16704        let doc = markdown_to_adf(md).unwrap();
16705        let para = &doc.content[0];
16706        assert_eq!(para.node_type, "paragraph");
16707        let content = para.content.as_ref().unwrap();
16708        // Find the text node containing "[Task]"
16709        let text_nodes: Vec<_> = content.iter().filter(|n| n.node_type == "text").collect();
16710        let has_task_bracket = text_nodes
16711            .iter()
16712            .any(|n| n.text.as_deref().unwrap_or("").contains("[Task]"));
16713        assert!(
16714            has_task_bracket,
16715            "expected [Task] in plain text, nodes: {content:?}"
16716        );
16717        // Also verify the (Link) is a proper link
16718        let link_nodes: Vec<_> = content
16719            .iter()
16720            .filter(|n| {
16721                n.marks
16722                    .as_ref()
16723                    .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "link"))
16724            })
16725            .collect();
16726        assert!(!link_nodes.is_empty(), "expected a link node");
16727        assert_eq!(
16728            link_nodes[0].text.as_deref(),
16729            Some("Link"),
16730            "link text should be 'Link'"
16731        );
16732    }
16733
16734    #[test]
16735    fn empty_paragraph_roundtrip() {
16736        // An empty ADF paragraph node should survive a round-trip through markdown
16737        let mut adf_in = AdfDocument::new();
16738        adf_in.content = vec![
16739            AdfNode::paragraph(vec![AdfNode::text("before")]),
16740            AdfNode::paragraph(vec![]),
16741            AdfNode::paragraph(vec![AdfNode::text("after")]),
16742        ];
16743        let md = adf_to_markdown(&adf_in).unwrap();
16744        let adf_out = markdown_to_adf(&md).unwrap();
16745        assert_eq!(
16746            adf_out.content.len(),
16747            3,
16748            "should have 3 blocks, markdown:\n{md}"
16749        );
16750        assert_eq!(adf_out.content[0].node_type, "paragraph");
16751        assert_eq!(adf_out.content[1].node_type, "paragraph");
16752        assert!(
16753            adf_out.content[1].content.is_none(),
16754            "middle paragraph should be empty"
16755        );
16756        assert_eq!(adf_out.content[2].node_type, "paragraph");
16757    }
16758
16759    #[test]
16760    fn nbsp_paragraph_roundtrip() {
16761        // Issue #411: paragraph with only NBSP should survive round-trip
16762        let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}]}";
16763        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16764        let md = adf_to_markdown(&doc).unwrap();
16765        assert!(
16766            md.contains("::paragraph["),
16767            "NBSP paragraph should use directive form: {md}"
16768        );
16769        let rt = markdown_to_adf(&md).unwrap();
16770        assert_eq!(rt.content.len(), 1, "should have 1 block");
16771        assert_eq!(rt.content[0].node_type, "paragraph");
16772        let text = rt.content[0].content.as_ref().unwrap()[0]
16773            .text
16774            .as_deref()
16775            .unwrap_or("");
16776        assert_eq!(text, "\u{00a0}", "NBSP should survive round-trip");
16777    }
16778
16779    #[test]
16780    fn nbsp_in_nested_expand_roundtrip() {
16781        // Issue #411 real-world case: NBSP paragraph inside nestedExpand
16782        let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"nestedExpand\",\"attrs\":{\"title\":\"Section\"},\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}]}]}";
16783        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16784        let md = adf_to_markdown(&doc).unwrap();
16785        let rt = markdown_to_adf(&md).unwrap();
16786        let ne = &rt.content[0];
16787        assert_eq!(ne.node_type, "nestedExpand");
16788        let inner = ne.content.as_ref().unwrap();
16789        assert_eq!(inner.len(), 1, "should have 1 inner block");
16790        assert_eq!(inner[0].node_type, "paragraph");
16791        let content = inner[0].content.as_ref().unwrap();
16792        assert!(!content.is_empty(), "paragraph should not be empty");
16793        let text = content[0].text.as_deref().unwrap_or("");
16794        assert_eq!(text, "\u{00a0}", "NBSP should survive in nestedExpand");
16795    }
16796
16797    #[test]
16798    fn nbsp_followed_by_content() {
16799        // NBSP paragraph followed by regular content should not interfere
16800        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\"}]}]}";
16801        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16802        let md = adf_to_markdown(&doc).unwrap();
16803        let rt = markdown_to_adf(&md).unwrap();
16804        assert!(rt.content.len() >= 2, "should have at least 2 blocks");
16805        // The second block should be a paragraph with "after"
16806        let after_para = rt.content.iter().find(|n| {
16807            n.node_type == "paragraph"
16808                && n.content
16809                    .as_ref()
16810                    .and_then(|c| c.first())
16811                    .and_then(|n| n.text.as_deref())
16812                    .is_some_and(|t| t.contains("after"))
16813        });
16814        assert!(after_para.is_some(), "should have paragraph with 'after'");
16815    }
16816
16817    #[test]
16818    fn nbsp_paragraph_with_marks_survives() {
16819        // NBSP with bold marks renders as `** **` which contains non-whitespace
16820        // chars and thus doesn't need the directive form — it round-trips naturally
16821        let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\",\"marks\":[{\"type\":\"strong\"}]}]}]}";
16822        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16823        let md = adf_to_markdown(&doc).unwrap();
16824        assert!(md.contains("**"), "should have bold markers: {md}");
16825        let rt = markdown_to_adf(&md).unwrap();
16826        let content = rt.content[0].content.as_ref().unwrap();
16827        assert!(!content.is_empty(), "should preserve content");
16828    }
16829
16830    #[test]
16831    fn regular_paragraph_unchanged() {
16832        // Regression guard: normal paragraphs should NOT use directive form
16833        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}"#;
16834        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16835        let md = adf_to_markdown(&doc).unwrap();
16836        assert!(
16837            !md.contains("::paragraph"),
16838            "regular paragraphs should not use directive form: {md}"
16839        );
16840        assert!(md.contains("hello"));
16841    }
16842
16843    #[test]
16844    fn paragraph_directive_with_content_parsed() {
16845        // ::paragraph[content] should parse to a paragraph with inline nodes
16846        let md = "::paragraph[\u{00a0}]\n";
16847        let doc = markdown_to_adf(md).unwrap();
16848        assert_eq!(doc.content.len(), 1);
16849        assert_eq!(doc.content[0].node_type, "paragraph");
16850        let content = doc.content[0].content.as_ref().unwrap();
16851        assert!(!content.is_empty(), "should have inline content");
16852        assert_eq!(content[0].text.as_deref().unwrap(), "\u{00a0}");
16853    }
16854
16855    #[test]
16856    fn nbsp_paragraph_in_list_item_with_nested_list() {
16857        // Issue #448: NBSP paragraph content lost inside listItem with nested bulletList
16858        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"}]}]}]}]}]}]}"#;
16859        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16860        let md = adf_to_markdown(&doc).unwrap();
16861        let rt = markdown_to_adf(&md).unwrap();
16862        let list = &rt.content[0];
16863        assert_eq!(list.node_type, "bulletList");
16864        let item = &list.content.as_ref().unwrap()[0];
16865        let item_content = item.content.as_ref().unwrap();
16866        assert_eq!(
16867            item_content.len(),
16868            2,
16869            "listItem should have paragraph + nested list, got: {item_content:?}"
16870        );
16871        let para = &item_content[0];
16872        assert_eq!(para.node_type, "paragraph");
16873        let para_content = para
16874            .content
16875            .as_ref()
16876            .expect("paragraph should have content");
16877        assert!(
16878            !para_content.is_empty(),
16879            "NBSP paragraph content should not be empty"
16880        );
16881        assert_eq!(
16882            para_content[0].text.as_deref().unwrap(),
16883            "\u{00a0}",
16884            "NBSP should survive round-trip inside listItem"
16885        );
16886    }
16887
16888    #[test]
16889    fn nbsp_paragraph_in_list_item_with_local_ids() {
16890        // Issue #448: NBSP paragraph with localIds inside listItem with nested list
16891        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"}]}]}]}]}]}]}"#;
16892        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16893        let md = adf_to_markdown(&doc).unwrap();
16894        let rt = markdown_to_adf(&md).unwrap();
16895        let list = &rt.content[0];
16896        let item = &list.content.as_ref().unwrap()[0];
16897        // Check listItem localId
16898        assert_eq!(
16899            item.attrs.as_ref().unwrap()["localId"],
16900            "li-001",
16901            "listItem localId should survive"
16902        );
16903        let item_content = item.content.as_ref().unwrap();
16904        assert_eq!(item_content.len(), 2);
16905        // Check paragraph localId and NBSP content
16906        let para = &item_content[0];
16907        assert_eq!(
16908            para.attrs.as_ref().unwrap()["localId"],
16909            "p-001",
16910            "paragraph localId should survive"
16911        );
16912        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
16913        assert_eq!(text, "\u{00a0}", "NBSP should survive with localIds");
16914    }
16915
16916    #[test]
16917    fn nbsp_paragraph_in_list_item_without_nested_list() {
16918        // NBSP paragraph in a simple listItem (no nested list)
16919        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"}]}]}]}]}"#;
16920        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16921        let md = adf_to_markdown(&doc).unwrap();
16922        let rt = markdown_to_adf(&md).unwrap();
16923        let list = &rt.content[0];
16924        let item = &list.content.as_ref().unwrap()[0];
16925        let para = &item.content.as_ref().unwrap()[0];
16926        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
16927        assert_eq!(text, "\u{00a0}", "NBSP should survive in simple list item");
16928    }
16929
16930    #[test]
16931    fn nbsp_paragraph_in_ordered_list_item_with_nested_list() {
16932        // NBSP paragraph in ordered listItem with nested bulletList
16933        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"}]}]}]}]}]}]}"#;
16934        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16935        let md = adf_to_markdown(&doc).unwrap();
16936        let rt = markdown_to_adf(&md).unwrap();
16937        let list = &rt.content[0];
16938        let item = &list.content.as_ref().unwrap()[0];
16939        let item_content = item.content.as_ref().unwrap();
16940        assert_eq!(item_content.len(), 2);
16941        let para = &item_content[0];
16942        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
16943        assert_eq!(text, "\u{00a0}", "NBSP should survive in ordered list item");
16944    }
16945
16946    #[test]
16947    fn list_item_leading_space_preserved() {
16948        // Leading space in list item text must not be stripped
16949        let md = "- hello world\n- - text";
16950        let doc = markdown_to_adf(md).unwrap();
16951        let list = &doc.content[0];
16952        assert_eq!(list.node_type, "bulletList");
16953        let items = list.content.as_ref().unwrap();
16954        // First item: "hello world" (no leading space, unchanged)
16955        let first_para = &items[0].content.as_ref().unwrap()[0];
16956        let first_text = &first_para.content.as_ref().unwrap()[0];
16957        assert_eq!(first_text.text.as_deref(), Some("hello world"));
16958    }
16959
16960    #[test]
16961    fn list_item_leading_space_not_stripped() {
16962        // When the markdown list item content has a leading space (e.g. " :emoji:"),
16963        // that space must reach parse_inline as-is.
16964        let md = "-  leading space text";
16965        let doc = markdown_to_adf(md).unwrap();
16966        let list = &doc.content[0];
16967        let items = list.content.as_ref().unwrap();
16968        let para = &items[0].content.as_ref().unwrap()[0];
16969        let text_node = &para.content.as_ref().unwrap()[0];
16970        // After "- " (2 chars), trim_end keeps the leading space: " leading space text"
16971        assert_eq!(
16972            text_node.text.as_deref(),
16973            Some(" leading space text"),
16974            "leading space should be preserved"
16975        );
16976    }
16977
16978    // ── Nested container directive tests ───────────────────────────
16979
16980    // ── hardBreak in table cell tests ────────────────────────────
16981
16982    #[test]
16983    fn hardbreak_in_cell_uses_directive_table() {
16984        // A table cell with a hardBreak should NOT use pipe syntax
16985        // because the newline would break the row
16986        let adf = AdfDocument {
16987            version: 1,
16988            doc_type: "doc".to_string(),
16989            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
16990                AdfNode::table_cell(vec![AdfNode::paragraph(vec![
16991                    AdfNode::text("before"),
16992                    AdfNode::hard_break(),
16993                    AdfNode::text("after"),
16994                ])]),
16995            ])])],
16996        };
16997        let md = adf_to_markdown(&adf).unwrap();
16998        // Should render as directive table, not pipe table
16999        assert!(
17000            md.contains(":::td") || md.contains("::::table"),
17001            "Table with hardBreak should use directive form, got:\n{md}"
17002        );
17003        assert!(
17004            !md.contains("| before"),
17005            "Should NOT use pipe syntax with hardBreak"
17006        );
17007    }
17008
17009    #[test]
17010    fn hardbreak_in_cell_roundtrips() {
17011        // Verify the directive table form preserves the hardBreak on round-trip
17012        let adf = AdfDocument {
17013            version: 1,
17014            doc_type: "doc".to_string(),
17015            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17016                AdfNode::table_cell(vec![AdfNode::paragraph(vec![
17017                    AdfNode::text("line one"),
17018                    AdfNode::hard_break(),
17019                    AdfNode::text("line two"),
17020                ])]),
17021            ])])],
17022        };
17023        let md = adf_to_markdown(&adf).unwrap();
17024        let roundtripped = markdown_to_adf(&md).unwrap();
17025
17026        // Should still have one table with one row with one cell
17027        assert_eq!(roundtripped.content.len(), 1);
17028        assert_eq!(roundtripped.content[0].node_type, "table");
17029        let rows = roundtripped.content[0].content.as_ref().unwrap();
17030        assert_eq!(
17031            rows.len(),
17032            1,
17033            "Should have exactly 1 row, got {}",
17034            rows.len()
17035        );
17036    }
17037
17038    #[test]
17039    fn hardbreak_in_paragraph_roundtrips() {
17040        // Issue #373: hardBreak absorbed into preceding text node
17041        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
17042          {"type":"text","text":"line one"},
17043          {"type":"hardBreak"},
17044          {"type":"text","text":"line two"}
17045        ]}]}"#;
17046        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17047        let md = adf_to_markdown(&doc).unwrap();
17048        let round_tripped = markdown_to_adf(&md).unwrap();
17049        let inlines = round_tripped.content[0].content.as_ref().unwrap();
17050        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17051        assert_eq!(
17052            types,
17053            vec!["text", "hardBreak", "text"],
17054            "hardBreak should be preserved, got: {types:?}"
17055        );
17056        assert_eq!(inlines[0].text.as_deref(), Some("line one"));
17057        assert_eq!(inlines[2].text.as_deref(), Some("line two"));
17058    }
17059
17060    #[test]
17061    fn consecutive_hardbreaks_in_paragraph_roundtrip() {
17062        // Issue #410: consecutive hardBreak nodes collapsed on round-trip
17063        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
17064          {"type":"text","text":"before"},
17065          {"type":"hardBreak"},
17066          {"type":"hardBreak"},
17067          {"type":"text","text":"after"}
17068        ]}]}"#;
17069        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17070        let md = adf_to_markdown(&doc).unwrap();
17071        let round_tripped = markdown_to_adf(&md).unwrap();
17072        assert_eq!(
17073            round_tripped.content.len(),
17074            1,
17075            "Should remain a single paragraph, got {} blocks",
17076            round_tripped.content.len()
17077        );
17078        let inlines = round_tripped.content[0].content.as_ref().unwrap();
17079        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17080        assert_eq!(
17081            types,
17082            vec!["text", "hardBreak", "hardBreak", "text"],
17083            "Both hardBreaks should be preserved, got: {types:?}"
17084        );
17085        assert_eq!(inlines[0].text.as_deref(), Some("before"));
17086        assert_eq!(inlines[3].text.as_deref(), Some("after"));
17087    }
17088
17089    #[test]
17090    fn hardbreak_only_paragraph_roundtrips() {
17091        // Issue #410: paragraph whose only content is a hardBreak is dropped
17092        let adf_json = r#"{"version":1,"type":"doc","content":[
17093          {"type":"paragraph","content":[{"type":"hardBreak"}]}
17094        ]}"#;
17095        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17096        let md = adf_to_markdown(&doc).unwrap();
17097        let round_tripped = markdown_to_adf(&md).unwrap();
17098        assert_eq!(
17099            round_tripped.content.len(),
17100            1,
17101            "Paragraph should not be dropped, got {} blocks",
17102            round_tripped.content.len()
17103        );
17104        let inlines = round_tripped.content[0].content.as_ref().unwrap();
17105        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17106        assert_eq!(
17107            types,
17108            vec!["hardBreak"],
17109            "hardBreak-only paragraph should preserve its content, got: {types:?}"
17110        );
17111    }
17112
17113    #[test]
17114    fn issue_410_full_reproducer_roundtrips() {
17115        // Full reproducer from issue #410: consecutive hardBreaks + hardBreak-only paragraph
17116        let adf_json = r#"{"version":1,"type":"doc","content":[
17117          {"type":"paragraph","content":[
17118            {"type":"text","text":"before"},
17119            {"type":"hardBreak"},
17120            {"type":"hardBreak"},
17121            {"type":"text","text":"after"}
17122          ]},
17123          {"type":"paragraph","content":[
17124            {"type":"hardBreak"}
17125          ]}
17126        ]}"#;
17127        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17128        let md = adf_to_markdown(&doc).unwrap();
17129        let round_tripped = markdown_to_adf(&md).unwrap();
17130        assert_eq!(
17131            round_tripped.content.len(),
17132            2,
17133            "Should have exactly 2 paragraphs, got {}",
17134            round_tripped.content.len()
17135        );
17136        // First paragraph: text, hardBreak, hardBreak, text
17137        let p1 = round_tripped.content[0].content.as_ref().unwrap();
17138        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
17139        assert_eq!(types1, vec!["text", "hardBreak", "hardBreak", "text"]);
17140        // Second paragraph: hardBreak only
17141        let p2 = round_tripped.content[1].content.as_ref().unwrap();
17142        let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
17143        assert_eq!(types2, vec!["hardBreak"]);
17144    }
17145
17146    #[test]
17147    fn trailing_space_hardbreak_still_parsed() {
17148        // Backward compatibility: trailing-space hardBreak (old JFM format) still parses
17149        let md = "line one  \nline two\n";
17150        let doc = markdown_to_adf(md).unwrap();
17151        let inlines = doc.content[0].content.as_ref().unwrap();
17152        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17153        assert_eq!(
17154            types,
17155            vec!["text", "hardBreak", "text"],
17156            "Trailing-space hardBreak should still parse, got: {types:?}"
17157        );
17158    }
17159
17160    #[test]
17161    fn trailing_hardbreak_at_end_of_paragraph_roundtrips() {
17162        // A paragraph ending with a hardBreak (no text after it)
17163        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
17164          {"type":"text","text":"text"},
17165          {"type":"hardBreak"}
17166        ]}]}"#;
17167        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17168        let md = adf_to_markdown(&doc).unwrap();
17169        let round_tripped = markdown_to_adf(&md).unwrap();
17170        let inlines = round_tripped.content[0].content.as_ref().unwrap();
17171        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17172        assert_eq!(
17173            types,
17174            vec!["text", "hardBreak"],
17175            "Trailing hardBreak should be preserved, got: {types:?}"
17176        );
17177    }
17178
17179    #[test]
17180    #[test]
17181    fn table_with_header_row_uses_pipe_syntax() {
17182        // A table with tableHeader in the first row should use pipe syntax
17183        let adf = AdfDocument {
17184            version: 1,
17185            doc_type: "doc".to_string(),
17186            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17187                AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("header cell")])]),
17188            ])])],
17189        };
17190        let md = adf_to_markdown(&adf).unwrap();
17191        assert!(
17192            md.contains("| header cell |"),
17193            "Table with header row should use pipe syntax, got:\n{md}"
17194        );
17195    }
17196
17197    #[test]
17198    fn table_without_header_row_uses_directive_syntax() {
17199        // Issue #392: tableCell-only first row must use directive syntax
17200        // to avoid converting tableCell → tableHeader on round-trip
17201        let adf = AdfDocument {
17202            version: 1,
17203            doc_type: "doc".to_string(),
17204            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17205                AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("simple cell")])]),
17206            ])])],
17207        };
17208        let md = adf_to_markdown(&adf).unwrap();
17209        assert!(
17210            md.contains("::::table"),
17211            "Table without header row should use directive syntax, got:\n{md}"
17212        );
17213    }
17214
17215    #[test]
17216    fn tablecell_first_row_preserved_on_roundtrip() {
17217        // Issue #392: tableCell in first row round-trips as tableHeader
17218        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{},"content":[
17219          {"type":"tableRow","content":[
17220            {"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"row1 cell"}]}]}
17221          ]},
17222          {"type":"tableRow","content":[
17223            {"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"row2 cell"}]}]}
17224          ]}
17225        ]}]}"#;
17226        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17227        let md = adf_to_markdown(&doc).unwrap();
17228        let round_tripped = markdown_to_adf(&md).unwrap();
17229        let rows = round_tripped.content[0].content.as_ref().unwrap();
17230        let row0_cell = &rows[0].content.as_ref().unwrap()[0];
17231        assert_eq!(
17232            row0_cell.node_type, "tableCell",
17233            "first row cell should remain tableCell, got: {}",
17234            row0_cell.node_type
17235        );
17236        let row1_cell = &rows[1].content.as_ref().unwrap()[0];
17237        assert_eq!(row1_cell.node_type, "tableCell");
17238    }
17239
17240    #[test]
17241    fn mixed_header_and_cell_first_row_uses_pipe() {
17242        // A first row with at least one tableHeader qualifies for pipe syntax
17243        let adf = AdfDocument {
17244            version: 1,
17245            doc_type: "doc".to_string(),
17246            content: vec![AdfNode::table(vec![
17247                AdfNode::table_row(vec![
17248                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
17249                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
17250                ]),
17251                AdfNode::table_row(vec![
17252                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C1")])]),
17253                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
17254                ]),
17255            ])],
17256        };
17257        let md = adf_to_markdown(&adf).unwrap();
17258        assert!(
17259            md.contains("| H1 |"),
17260            "Table with header first row should use pipe syntax, got:\n{md}"
17261        );
17262        assert!(!md.contains("::::table"), "should not use directive syntax");
17263    }
17264
17265    // ── Issue #579: pipes in pipe-table cells ─────────────────────
17266
17267    #[test]
17268    fn render_pipe_table_escapes_pipe_in_code_span_cell() {
17269        // A code-marked text node with a literal `|` in a pipe-table cell
17270        // must emit `\|` so the column separator is unambiguous.
17271        let adf = AdfDocument {
17272            version: 1,
17273            doc_type: "doc".to_string(),
17274            content: vec![AdfNode::table(vec![
17275                AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
17276                    AdfNode::text("Header"),
17277                ])])]),
17278                AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
17279                    AdfNode::text_with_marks("a|b", vec![AdfMark::code()]),
17280                ])])]),
17281            ])],
17282        };
17283        let md = adf_to_markdown(&adf).unwrap();
17284        assert!(
17285            md.contains(r"`a\|b`"),
17286            "Pipe inside code span must be escaped, got:\n{md}"
17287        );
17288    }
17289
17290    #[test]
17291    fn render_pipe_table_escapes_pipe_in_plain_text_cell() {
17292        let adf = AdfDocument {
17293            version: 1,
17294            doc_type: "doc".to_string(),
17295            content: vec![AdfNode::table(vec![
17296                AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
17297                    AdfNode::text("Header"),
17298                ])])]),
17299                AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
17300                    AdfNode::text("x|y"),
17301                ])])]),
17302            ])],
17303        };
17304        let md = adf_to_markdown(&adf).unwrap();
17305        assert!(
17306            md.contains(r"x\|y"),
17307            "Pipe inside plain-text cell must be escaped, got:\n{md}"
17308        );
17309    }
17310
17311    #[test]
17312    fn code_span_with_pipe_in_table_cell_roundtrips() {
17313        // Issue #579 reproducer: code span containing `|` in a pipe-table cell.
17314        let adf_json = r#"{
17315            "version": 1,
17316            "type": "doc",
17317            "content": [{
17318                "type": "table",
17319                "attrs": {"isNumberColumnEnabled": false, "layout": "default", "localId": "abc-789"},
17320                "content": [
17321                    {"type": "tableRow", "content": [
17322                        {"type": "tableHeader", "attrs": {}, "content": [
17323                            {"type": "paragraph", "content": [{"type": "text", "text": "Before"}]}
17324                        ]},
17325                        {"type": "tableHeader", "attrs": {}, "content": [
17326                            {"type": "paragraph", "content": [{"type": "text", "text": "After"}]}
17327                        ]}
17328                    ]},
17329                    {"type": "tableRow", "content": [
17330                        {"type": "tableCell", "attrs": {}, "content": [
17331                            {"type": "paragraph", "content": [
17332                                {"type": "text", "text": "parse(json).extract[T]", "marks": [{"type": "code"}]}
17333                            ]}
17334                        ]},
17335                        {"type": "tableCell", "attrs": {}, "content": [
17336                            {"type": "paragraph", "content": [
17337                                {"type": "text", "text": "parser.decode[T|json]", "marks": [{"type": "code"}]}
17338                            ]}
17339                        ]}
17340                    ]}
17341                ]
17342            }]
17343        }"#;
17344        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17345        let md = adf_to_markdown(&doc).unwrap();
17346        let round_tripped = markdown_to_adf(&md).unwrap();
17347
17348        let rows = round_tripped.content[0].content.as_ref().unwrap();
17349        assert_eq!(
17350            rows.len(),
17351            2,
17352            "Table should have 2 rows, got: {}",
17353            rows.len()
17354        );
17355
17356        let body_row = rows[1].content.as_ref().unwrap();
17357        assert_eq!(
17358            body_row.len(),
17359            2,
17360            "Body row should have 2 cells (not split by the pipe), got: {}",
17361            body_row.len()
17362        );
17363
17364        let second_cell = &body_row[1];
17365        let para = second_cell.content.as_ref().unwrap().first().unwrap();
17366        let inlines = para.content.as_ref().unwrap();
17367        assert_eq!(inlines.len(), 1, "Cell should have a single text node");
17368        assert_eq!(
17369            inlines[0].text.as_deref(),
17370            Some("parser.decode[T|json]"),
17371            "Code-span text must be preserved with literal pipe"
17372        );
17373        let marks = inlines[0]
17374            .marks
17375            .as_ref()
17376            .expect("code mark must be preserved");
17377        assert!(
17378            marks.iter().any(|m| m.mark_type == "code"),
17379            "text node should carry the code mark"
17380        );
17381    }
17382
17383    #[test]
17384    fn plain_text_pipe_in_table_cell_roundtrips() {
17385        // Plain text with `|` in a pipe-table cell should also survive.
17386        let adf = AdfDocument {
17387            version: 1,
17388            doc_type: "doc".to_string(),
17389            content: vec![AdfNode::table(vec![
17390                AdfNode::table_row(vec![
17391                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
17392                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
17393                ]),
17394                AdfNode::table_row(vec![
17395                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("a|b")])]),
17396                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("c")])]),
17397                ]),
17398            ])],
17399        };
17400        let md = adf_to_markdown(&adf).unwrap();
17401        let round_tripped = markdown_to_adf(&md).unwrap();
17402        let rows = round_tripped.content[0].content.as_ref().unwrap();
17403        let body_row = rows[1].content.as_ref().unwrap();
17404        assert_eq!(
17405            body_row.len(),
17406            2,
17407            "Body row should keep 2 cells, got: {}",
17408            body_row.len()
17409        );
17410        let first_cell_text = body_row[0].content.as_ref().unwrap()[0]
17411            .content
17412            .as_ref()
17413            .unwrap()[0]
17414            .text
17415            .as_deref();
17416        assert_eq!(first_cell_text, Some("a|b"));
17417    }
17418
17419    #[test]
17420    fn cell_contains_hard_break_true() {
17421        let para = AdfNode::paragraph(vec![
17422            AdfNode::text("a"),
17423            AdfNode::hard_break(),
17424            AdfNode::text("b"),
17425        ]);
17426        assert!(cell_contains_hard_break(&para));
17427    }
17428
17429    #[test]
17430    fn cell_contains_hard_break_false() {
17431        let para = AdfNode::paragraph(vec![AdfNode::text("no break here")]);
17432        assert!(!cell_contains_hard_break(&para));
17433    }
17434
17435    #[test]
17436    fn cell_contains_hard_break_empty() {
17437        let para = AdfNode::paragraph(vec![]);
17438        assert!(!cell_contains_hard_break(&para));
17439    }
17440
17441    // ── Multi-paragraph container tests ──────────────────────────
17442
17443    #[test]
17444    fn multi_paragraph_panel_roundtrips() {
17445        let adf = AdfDocument {
17446            version: 1,
17447            doc_type: "doc".to_string(),
17448            content: vec![AdfNode {
17449                node_type: "panel".to_string(),
17450                attrs: Some(serde_json::json!({"panelType": "info"})),
17451                content: Some(vec![
17452                    AdfNode::paragraph(vec![AdfNode::text("First paragraph.")]),
17453                    AdfNode::paragraph(vec![AdfNode::text("Second paragraph.")]),
17454                ]),
17455                text: None,
17456                marks: None,
17457                local_id: None,
17458                parameters: None,
17459            }],
17460        };
17461
17462        let md = adf_to_markdown(&adf).unwrap();
17463        // Should have blank line between paragraphs inside the panel
17464        assert!(
17465            md.contains("First paragraph.\n\nSecond paragraph."),
17466            "Panel should have blank line between paragraphs, got:\n{md}"
17467        );
17468
17469        // Round-trip should preserve two separate paragraphs
17470        let roundtripped = markdown_to_adf(&md).unwrap();
17471        assert_eq!(roundtripped.content.len(), 1);
17472        assert_eq!(roundtripped.content[0].node_type, "panel");
17473        let panel_content = roundtripped.content[0].content.as_ref().unwrap();
17474        assert_eq!(
17475            panel_content.len(),
17476            2,
17477            "Panel should have 2 paragraphs after round-trip, got {}",
17478            panel_content.len()
17479        );
17480    }
17481
17482    #[test]
17483    fn multi_paragraph_expand_roundtrips() {
17484        let adf = AdfDocument {
17485            version: 1,
17486            doc_type: "doc".to_string(),
17487            content: vec![AdfNode {
17488                node_type: "expand".to_string(),
17489                attrs: Some(serde_json::json!({"title": "Details"})),
17490                content: Some(vec![
17491                    AdfNode::paragraph(vec![AdfNode::text("Para one.")]),
17492                    AdfNode::paragraph(vec![AdfNode::text("Para two.")]),
17493                ]),
17494                text: None,
17495                marks: None,
17496                local_id: None,
17497                parameters: None,
17498            }],
17499        };
17500
17501        let md = adf_to_markdown(&adf).unwrap();
17502        let roundtripped = markdown_to_adf(&md).unwrap();
17503        let expand_content = roundtripped.content[0].content.as_ref().unwrap();
17504        assert_eq!(
17505            expand_content.len(),
17506            2,
17507            "Expand should have 2 paragraphs after round-trip, got {}",
17508            expand_content.len()
17509        );
17510    }
17511
17512    #[test]
17513    fn consecutive_nested_expands_in_table_cell_roundtrip() {
17514        let cell_content = vec![
17515            AdfNode {
17516                node_type: "nestedExpand".to_string(),
17517                attrs: Some(serde_json::json!({"title": "First"})),
17518                content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("item 1")])]),
17519                text: None,
17520                marks: None,
17521                local_id: None,
17522                parameters: None,
17523            },
17524            AdfNode {
17525                node_type: "nestedExpand".to_string(),
17526                attrs: Some(serde_json::json!({"title": "Second"})),
17527                content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("item 2")])]),
17528                text: None,
17529                marks: None,
17530                local_id: None,
17531                parameters: None,
17532            },
17533        ];
17534        let adf = AdfDocument {
17535            version: 1,
17536            doc_type: "doc".to_string(),
17537            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17538                AdfNode::table_cell(cell_content),
17539            ])])],
17540        };
17541
17542        let md = adf_to_markdown(&adf).unwrap();
17543        assert!(
17544            md.contains(":::\n\n:::nested-expand"),
17545            "Should have blank line between consecutive nested-expands in cell, got:\n{md}"
17546        );
17547
17548        let rt = markdown_to_adf(&md).unwrap();
17549        let cell = &rt.content[0].content.as_ref().unwrap()[0]
17550            .content
17551            .as_ref()
17552            .unwrap()[0];
17553        let cell_nodes = cell.content.as_ref().unwrap();
17554        let expand_count = cell_nodes
17555            .iter()
17556            .filter(|n| n.node_type == "nestedExpand")
17557            .count();
17558        assert_eq!(
17559            expand_count, 2,
17560            "Both nested-expands should survive round-trip, got {expand_count}"
17561        );
17562    }
17563
17564    #[test]
17565    fn multi_paragraph_in_table_cell_roundtrip() {
17566        // Two paragraphs inside a directive table cell should survive round-trip
17567        let adf = AdfDocument {
17568            version: 1,
17569            doc_type: "doc".to_string(),
17570            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17571                AdfNode::table_cell(vec![
17572                    AdfNode::paragraph(vec![AdfNode::text("Para one.")]),
17573                    AdfNode::paragraph(vec![AdfNode::text("Para two.")]),
17574                ]),
17575            ])])],
17576        };
17577
17578        let md = adf_to_markdown(&adf).unwrap();
17579        assert!(
17580            md.contains("Para one.\n\nPara two."),
17581            "Should have blank line between paragraphs in cell, got:\n{md}"
17582        );
17583
17584        let rt = markdown_to_adf(&md).unwrap();
17585        let cell = &rt.content[0].content.as_ref().unwrap()[0]
17586            .content
17587            .as_ref()
17588            .unwrap()[0];
17589        let para_count = cell
17590            .content
17591            .as_ref()
17592            .unwrap()
17593            .iter()
17594            .filter(|n| n.node_type == "paragraph")
17595            .count();
17596        assert_eq!(para_count, 2, "Both paragraphs should survive round-trip");
17597    }
17598
17599    #[test]
17600    fn panel_inside_table_cell_roundtrip() {
17601        // A panel inside a directive table cell
17602        let adf = AdfDocument {
17603            version: 1,
17604            doc_type: "doc".to_string(),
17605            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17606                AdfNode::table_cell(vec![
17607                    AdfNode::paragraph(vec![AdfNode::text("Before panel.")]),
17608                    AdfNode {
17609                        node_type: "panel".to_string(),
17610                        attrs: Some(serde_json::json!({"panelType": "info"})),
17611                        content: Some(vec![AdfNode::paragraph(vec![AdfNode::text(
17612                            "Panel content",
17613                        )])]),
17614                        text: None,
17615                        marks: None,
17616                        local_id: None,
17617                        parameters: None,
17618                    },
17619                ]),
17620            ])])],
17621        };
17622
17623        let md = adf_to_markdown(&adf).unwrap();
17624        assert!(
17625            md.contains(":::panel"),
17626            "Should contain panel directive, got:\n{md}"
17627        );
17628
17629        let rt = markdown_to_adf(&md).unwrap();
17630        let cell = &rt.content[0].content.as_ref().unwrap()[0]
17631            .content
17632            .as_ref()
17633            .unwrap()[0];
17634        let has_panel = cell
17635            .content
17636            .as_ref()
17637            .unwrap()
17638            .iter()
17639            .any(|n| n.node_type == "panel");
17640        assert!(has_panel, "Panel should survive round-trip in table cell");
17641    }
17642
17643    #[test]
17644    fn three_consecutive_expands_in_table_cell() {
17645        let make_expand = |title: &str| AdfNode {
17646            node_type: "nestedExpand".to_string(),
17647            attrs: Some(serde_json::json!({"title": title})),
17648            content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("content")])]),
17649            text: None,
17650            marks: None,
17651            local_id: None,
17652            parameters: None,
17653        };
17654        let adf = AdfDocument {
17655            version: 1,
17656            doc_type: "doc".to_string(),
17657            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17658                AdfNode::table_cell(vec![
17659                    make_expand("First"),
17660                    make_expand("Second"),
17661                    make_expand("Third"),
17662                ]),
17663            ])])],
17664        };
17665
17666        let md = adf_to_markdown(&adf).unwrap();
17667        let rt = markdown_to_adf(&md).unwrap();
17668        let cell = &rt.content[0].content.as_ref().unwrap()[0]
17669            .content
17670            .as_ref()
17671            .unwrap()[0];
17672        let expand_count = cell
17673            .content
17674            .as_ref()
17675            .unwrap()
17676            .iter()
17677            .filter(|n| n.node_type == "nestedExpand")
17678            .count();
17679        assert_eq!(expand_count, 3, "All 3 expands should survive round-trip");
17680    }
17681
17682    // ── Nested container directive tests ───────────────────────────
17683
17684    #[test]
17685    fn nested_expand_inside_panel() {
17686        // Issue #714: the converter still produces panel→expand at the AST
17687        // level (this is what users may type), but the document fails ADF
17688        // schema validation, surfacing an actionable error before the API
17689        // call rather than an opaque Confluence HTTP 500.
17690        let md = ":::panel{type=info}\n:::expand{title=\"Details\"}\nHidden content\n:::\nMore panel content\n:::";
17691        let adf = markdown_to_adf(md).unwrap();
17692
17693        let err = crate::atlassian::adf_validated::validate(&adf).unwrap_err();
17694        assert!(err.violations.iter().any(|v| matches!(
17695            v,
17696            crate::atlassian::adf_schema::AdfSchemaViolation::DisallowedChild {
17697                parent_type, child_type, ..
17698            } if parent_type == "panel" && child_type == "expand"
17699        )));
17700    }
17701
17702    #[test]
17703    fn nested_expand_inside_table_cell() {
17704        // Issue #714: tableCell → expand is a Confluence content-model
17705        // violation. Table cells require `nestedExpand` instead. Validation
17706        // catches this before the API call.
17707        let md = "::::table\n:::tr\n:::td\n:::expand{title=\"Details\"}\nExpand content\n:::\n:::\n:::\n::::";
17708        let adf = markdown_to_adf(md).unwrap();
17709
17710        let err = crate::atlassian::adf_validated::validate(&adf).unwrap_err();
17711        assert!(err.violations.iter().any(|v| matches!(
17712            v,
17713            crate::atlassian::adf_schema::AdfSchemaViolation::DisallowedChild {
17714                parent_type, child_type, ..
17715            } if parent_type == "tableCell" && child_type == "expand"
17716        )));
17717    }
17718
17719    #[test]
17720    fn nested_expand_inside_layout_column() {
17721        // Issue #714 sanity check: `expand` inside a `layoutColumn` is
17722        // legitimate per the ADF schema and must NOT trigger validation.
17723        // Note: layoutSection requires 2..=3 columns per #733's quantifier
17724        // checks, so the markdown declares two columns.
17725        let md = ":::layout\n:::column{width=50}\n:::expand{title=\"Col Expand\"}\nExpanded\n:::\n:::\n:::column{width=50}\nFiller paragraph.\n:::\n:::";
17726        let adf = markdown_to_adf(md).unwrap();
17727
17728        assert_eq!(adf.content.len(), 1);
17729        assert_eq!(adf.content[0].node_type, "layoutSection");
17730
17731        let columns = adf.content[0].content.as_ref().unwrap();
17732        assert_eq!(columns.len(), 2);
17733        let col_content = columns[0].content.as_ref().unwrap();
17734        assert!(
17735            col_content.iter().any(|n| n.node_type == "expand"),
17736            "Column should contain an expand node, got: {:?}",
17737            col_content.iter().map(|n| &n.node_type).collect::<Vec<_>>()
17738        );
17739
17740        // Validation must not flag this legitimate nesting.
17741        crate::atlassian::adf_validated::validate(&adf).unwrap();
17742    }
17743
17744    #[test]
17745    fn expand_localid_in_directive_attrs() {
17746        // Issue #412: localId should be in directive attrs, not trailing text
17747        let adf_json = r#"{"version":1,"type":"doc","content":[
17748          {"type":"expand","attrs":{"localId":"exp-001","title":"Details"},"content":[
17749            {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17750          ]}
17751        ]}"#;
17752        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17753        let md = adf_to_markdown(&doc).unwrap();
17754        assert!(
17755            md.contains("localId=exp-001"),
17756            "should contain localId: {md}"
17757        );
17758        assert!(
17759            md.contains(":::expand{"),
17760            "should have expand directive with attrs: {md}"
17761        );
17762        assert!(
17763            !md.contains(":::\n{localId="),
17764            "localId should NOT be trailing: {md}"
17765        );
17766    }
17767
17768    #[test]
17769    fn expand_localid_roundtrip() {
17770        let adf_json = r#"{"version":1,"type":"doc","content":[
17771          {"type":"expand","attrs":{"localId":"exp-001","title":"Details"},"content":[
17772            {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17773          ]}
17774        ]}"#;
17775        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17776        let md = adf_to_markdown(&doc).unwrap();
17777        let rt = markdown_to_adf(&md).unwrap();
17778        let expand = &rt.content[0];
17779        assert_eq!(expand.node_type, "expand");
17780        assert_eq!(
17781            expand.local_id.as_deref(),
17782            Some("exp-001"),
17783            "expand localId should survive round-trip"
17784        );
17785        assert_eq!(
17786            expand.attrs.as_ref().unwrap()["title"],
17787            "Details",
17788            "expand title should survive round-trip"
17789        );
17790    }
17791
17792    #[test]
17793    fn nested_expand_localid_roundtrip() {
17794        let adf_json = r#"{"version":1,"type":"doc","content":[
17795          {"type":"nestedExpand","attrs":{"localId":"ne-001","title":"S"},"content":[
17796            {"type":"paragraph","content":[{"type":"text","text":"content"}]}
17797          ]}
17798        ]}"#;
17799        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17800        let md = adf_to_markdown(&doc).unwrap();
17801        assert!(
17802            md.contains(":::nested-expand{"),
17803            "should have directive: {md}"
17804        );
17805        assert!(md.contains("localId=ne-001"), "should have localId: {md}");
17806        let rt = markdown_to_adf(&md).unwrap();
17807        let ne = &rt.content[0];
17808        assert_eq!(ne.node_type, "nestedExpand");
17809        assert_eq!(ne.local_id.as_deref(), Some("ne-001"));
17810    }
17811
17812    #[test]
17813    fn nested_expand_localid_followed_by_content() {
17814        // Issue #412 reproducer: localId must not leak into following paragraph
17815        let adf_json = "{\
17816            \"version\":1,\"type\":\"doc\",\"content\":[\
17817              {\"type\":\"nestedExpand\",\"attrs\":{\"localId\":\"exp-001\",\"title\":\"S\"},\"content\":[\
17818                {\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}\
17819              ]},\
17820              {\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"after\"}]}\
17821            ]}";
17822        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17823        let md = adf_to_markdown(&doc).unwrap();
17824        let rt = markdown_to_adf(&md).unwrap();
17825        // nestedExpand should have localId
17826        let ne = &rt.content[0];
17827        assert_eq!(ne.node_type, "nestedExpand");
17828        assert_eq!(
17829            ne.local_id.as_deref(),
17830            Some("exp-001"),
17831            "nestedExpand should preserve localId"
17832        );
17833        // Following paragraph should contain "after", not "{localId=...}"
17834        let para = &rt.content[1];
17835        assert_eq!(para.node_type, "paragraph");
17836        let text = para.content.as_ref().unwrap()[0]
17837            .text
17838            .as_deref()
17839            .unwrap_or("");
17840        assert!(
17841            !text.contains("localId"),
17842            "following paragraph should not contain localId: {text}"
17843        );
17844        assert!(
17845            text.contains("after"),
17846            "following paragraph should contain 'after': {text}"
17847        );
17848    }
17849
17850    #[test]
17851    fn expand_localid_without_title() {
17852        let adf_json = r#"{"version":1,"type":"doc","content":[
17853          {"type":"expand","attrs":{"localId":"exp-002"},"content":[
17854            {"type":"paragraph","content":[{"type":"text","text":"no title"}]}
17855          ]}
17856        ]}"#;
17857        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17858        let md = adf_to_markdown(&doc).unwrap();
17859        assert!(
17860            md.contains(":::expand{localId=exp-002}"),
17861            "should have localId without title: {md}"
17862        );
17863        let rt = markdown_to_adf(&md).unwrap();
17864        assert_eq!(rt.content[0].local_id.as_deref(), Some("exp-002"));
17865    }
17866
17867    #[test]
17868    fn expand_localid_stripped() {
17869        let adf_json = r#"{"version":1,"type":"doc","content":[
17870          {"type":"expand","attrs":{"localId":"exp-001","title":"X"},"content":[
17871            {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17872          ]}
17873        ]}"#;
17874        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17875        let opts = RenderOptions {
17876            strip_local_ids: true,
17877        };
17878        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
17879        assert!(!md.contains("localId"), "localId should be stripped: {md}");
17880        assert!(
17881            md.contains(":::expand{title=\"X\"}"),
17882            "title should remain: {md}"
17883        );
17884    }
17885
17886    // ── Issue #444: top-level localId and parameters on expand ──
17887
17888    #[test]
17889    fn expand_top_level_localid_roundtrip() {
17890        // localId as a top-level field (not inside attrs) should survive round-trip
17891        let adf_json = r#"{"version":1,"type":"doc","content":[
17892          {"type":"expand","attrs":{"title":"My Section"},"localId":"abc-123","content":[
17893            {"type":"paragraph","content":[{"type":"text","text":"hello"}]}
17894          ]}
17895        ]}"#;
17896        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17897        assert_eq!(doc.content[0].local_id.as_deref(), Some("abc-123"));
17898        let md = adf_to_markdown(&doc).unwrap();
17899        assert!(
17900            md.contains("localId=abc-123"),
17901            "JFM should contain localId: {md}"
17902        );
17903        let rt = markdown_to_adf(&md).unwrap();
17904        let expand = &rt.content[0];
17905        assert_eq!(expand.node_type, "expand");
17906        assert_eq!(expand.local_id.as_deref(), Some("abc-123"));
17907        assert_eq!(
17908            expand.attrs.as_ref().unwrap()["title"],
17909            "My Section",
17910            "title should survive round-trip"
17911        );
17912    }
17913
17914    #[test]
17915    fn expand_parameters_roundtrip() {
17916        // parameters (macroMetadata) should survive round-trip
17917        let adf_json = r#"{"version":1,"type":"doc","content":[
17918          {"type":"expand","attrs":{"title":"Props"},"parameters":{"macroMetadata":{"macroId":{"value":"m-001"},"schemaVersion":{"value":"1"}}},"content":[
17919            {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17920          ]}
17921        ]}"#;
17922        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17923        assert!(doc.content[0].parameters.is_some());
17924        let md = adf_to_markdown(&doc).unwrap();
17925        assert!(md.contains("params="), "JFM should contain params: {md}");
17926        let rt = markdown_to_adf(&md).unwrap();
17927        let expand = &rt.content[0];
17928        let params = expand
17929            .parameters
17930            .as_ref()
17931            .expect("parameters should survive round-trip");
17932        assert_eq!(params["macroMetadata"]["macroId"]["value"], "m-001");
17933        assert_eq!(params["macroMetadata"]["schemaVersion"]["value"], "1");
17934    }
17935
17936    #[test]
17937    fn expand_localid_and_parameters_roundtrip() {
17938        // Issue #444: both localId and parameters on expand should survive round-trip
17939        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"}]}]}]}"#;
17940        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17941        let md = adf_to_markdown(&doc).unwrap();
17942        let rt = markdown_to_adf(&md).unwrap();
17943        let expand = &rt.content[0];
17944        assert_eq!(expand.node_type, "expand");
17945        assert_eq!(expand.local_id.as_deref(), Some("abc-123"));
17946        assert_eq!(expand.attrs.as_ref().unwrap()["title"], "My Section");
17947        let params = expand
17948            .parameters
17949            .as_ref()
17950            .expect("parameters should survive");
17951        assert_eq!(params["macroMetadata"]["macroId"]["value"], "macro-001");
17952        assert_eq!(params["macroMetadata"]["title"], "Page Properties");
17953    }
17954
17955    #[test]
17956    fn nested_expand_top_level_localid_and_parameters_roundtrip() {
17957        let adf_json = r#"{"version":1,"type":"doc","content":[
17958          {"type":"nestedExpand","attrs":{"title":"Nested"},"localId":"ne-100","parameters":{"macroMetadata":{"macroId":{"value":"nm-001"}}},"content":[
17959            {"type":"paragraph","content":[{"type":"text","text":"inner"}]}
17960          ]}
17961        ]}"#;
17962        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17963        let md = adf_to_markdown(&doc).unwrap();
17964        assert!(
17965            md.contains(":::nested-expand{"),
17966            "should use nested-expand: {md}"
17967        );
17968        assert!(md.contains("localId=ne-100"), "should have localId: {md}");
17969        assert!(md.contains("params="), "should have params: {md}");
17970        let rt = markdown_to_adf(&md).unwrap();
17971        let ne = &rt.content[0];
17972        assert_eq!(ne.node_type, "nestedExpand");
17973        assert_eq!(ne.local_id.as_deref(), Some("ne-100"));
17974        assert_eq!(
17975            ne.parameters.as_ref().unwrap()["macroMetadata"]["macroId"]["value"],
17976            "nm-001"
17977        );
17978    }
17979
17980    #[test]
17981    fn expand_top_level_localid_stripped() {
17982        // strip_local_ids should strip top-level localId too
17983        let adf_json = r#"{"version":1,"type":"doc","content":[
17984          {"type":"expand","attrs":{"title":"X"},"localId":"exp-strip","content":[
17985            {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17986          ]}
17987        ]}"#;
17988        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17989        let opts = RenderOptions {
17990            strip_local_ids: true,
17991        };
17992        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
17993        assert!(!md.contains("localId"), "localId should be stripped: {md}");
17994        assert!(
17995            md.contains(":::expand{title=\"X\"}"),
17996            "title should remain: {md}"
17997        );
17998    }
17999
18000    #[test]
18001    fn expand_parameters_without_localid() {
18002        // parameters without localId should work
18003        let adf_json = r#"{"version":1,"type":"doc","content":[
18004          {"type":"expand","attrs":{"title":"P"},"parameters":{"macroMetadata":{"macroId":{"value":"solo"}}},"content":[
18005            {"type":"paragraph","content":[{"type":"text","text":"data"}]}
18006          ]}
18007        ]}"#;
18008        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18009        let md = adf_to_markdown(&doc).unwrap();
18010        assert!(!md.contains("localId"), "no localId: {md}");
18011        assert!(md.contains("params="), "has params: {md}");
18012        let rt = markdown_to_adf(&md).unwrap();
18013        assert!(rt.content[0].local_id.is_none());
18014        assert_eq!(
18015            rt.content[0].parameters.as_ref().unwrap()["macroMetadata"]["macroId"]["value"],
18016            "solo"
18017        );
18018    }
18019
18020    #[test]
18021    fn expand_localid_without_parameters() {
18022        // top-level localId without parameters should work
18023        let adf_json = r#"{"version":1,"type":"doc","content":[
18024          {"type":"expand","attrs":{"title":"L"},"localId":"lid-only","content":[
18025            {"type":"paragraph","content":[{"type":"text","text":"txt"}]}
18026          ]}
18027        ]}"#;
18028        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18029        let md = adf_to_markdown(&doc).unwrap();
18030        assert!(md.contains("localId=lid-only"), "has localId: {md}");
18031        assert!(!md.contains("params="), "no params: {md}");
18032        let rt = markdown_to_adf(&md).unwrap();
18033        assert_eq!(rt.content[0].local_id.as_deref(), Some("lid-only"));
18034        assert!(rt.content[0].parameters.is_none());
18035    }
18036
18037    #[test]
18038    fn nested_panel_inside_panel() {
18039        let md = ":::panel{type=info}\n:::panel{type=warning}\nInner warning\n:::\n:::";
18040        let adf = markdown_to_adf(md).unwrap();
18041
18042        // Outer panel should exist
18043        assert_eq!(adf.content.len(), 1);
18044        assert_eq!(adf.content[0].node_type, "panel");
18045
18046        // Outer panel should contain an inner panel (not have it truncated)
18047        let panel_content = adf.content[0].content.as_ref().unwrap();
18048        assert!(
18049            panel_content.iter().any(|n| n.node_type == "panel"),
18050            "Outer panel should contain an inner panel, got: {:?}",
18051            panel_content
18052                .iter()
18053                .map(|n| &n.node_type)
18054                .collect::<Vec<_>>()
18055        );
18056    }
18057
18058    #[test]
18059    fn content_after_directive_table_is_preserved() {
18060        // Issue #361: content after a ::::table block was silently dropped
18061        let md = "\
18062## Before table
18063
18064::::table{layout=default}
18065:::tr
18066:::th{}
18067Cell
18068:::
18069:::
18070::::
18071
18072## After table
18073
18074Paragraph after.";
18075        let adf = markdown_to_adf(md).unwrap();
18076        let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
18077        assert_eq!(
18078            types,
18079            vec!["heading", "table", "heading", "paragraph"],
18080            "Content after table was dropped: got {types:?}"
18081        );
18082    }
18083
18084    #[test]
18085    fn paragraph_after_directive_table_is_preserved() {
18086        // Issue #361: minimal reproducer — paragraph after table
18087        let md = "\
18088::::table{layout=default}
18089:::tr
18090:::th{}
18091Header
18092:::
18093:::
18094::::
18095
18096Just a paragraph.";
18097        let adf = markdown_to_adf(md).unwrap();
18098        let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
18099        assert_eq!(
18100            types,
18101            vec!["table", "paragraph"],
18102            "Paragraph after table was dropped: got {types:?}"
18103        );
18104    }
18105
18106    #[test]
18107    fn extension_after_directive_table_is_preserved() {
18108        // Issue #361: extension after table
18109        let md = "\
18110::::table{layout=default}
18111:::tr
18112:::th{}
18113Header
18114:::
18115:::
18116::::
18117
18118::extension{type=com.atlassian.confluence.macro.core key=toc}";
18119        let adf = markdown_to_adf(md).unwrap();
18120        let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
18121        assert_eq!(
18122            types,
18123            vec!["table", "extension"],
18124            "Extension after table was dropped: got {types:?}"
18125        );
18126    }
18127
18128    #[test]
18129    fn multiple_blocks_after_directive_table() {
18130        // Issue #361: multiple blocks after table, including another table
18131        let md = "\
18132## Heading 1
18133
18134::::table{layout=default}
18135:::tr
18136:::td{}
18137A
18138:::
18139:::td{}
18140B
18141:::
18142:::
18143::::
18144
18145## Heading 2
18146
18147Some text.
18148
18149---
18150
18151::::table{layout=default}
18152:::tr
18153:::th{}
18154C
18155:::
18156:::
18157::::
18158
18159## Heading 3";
18160        let adf = markdown_to_adf(md).unwrap();
18161        let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
18162        assert_eq!(
18163            types,
18164            vec![
18165                "heading",
18166                "table",
18167                "heading",
18168                "paragraph",
18169                "rule",
18170                "table",
18171                "heading"
18172            ],
18173            "Content after tables was dropped: got {types:?}"
18174        );
18175    }
18176
18177    // ── Table caption tests (issue #382) ────────────────────────────
18178
18179    #[test]
18180    fn adf_table_caption_to_markdown() {
18181        let doc = AdfDocument {
18182            version: 1,
18183            doc_type: "doc".to_string(),
18184            content: vec![AdfNode::table(vec![
18185                AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
18186                    AdfNode::text("cell"),
18187                ])])]),
18188                AdfNode::caption(vec![AdfNode::text("Table caption")]),
18189            ])],
18190        };
18191        let md = adf_to_markdown(&doc).unwrap();
18192        assert!(
18193            md.contains("::::table"),
18194            "table with caption must use directive form"
18195        );
18196        assert!(
18197            md.contains(":::caption"),
18198            "caption directive missing, got: {md}"
18199        );
18200        assert!(
18201            md.contains("Table caption"),
18202            "caption text missing, got: {md}"
18203        );
18204    }
18205
18206    #[test]
18207    fn directive_table_caption_parses() {
18208        let md = "::::table\n:::tr\n:::td\ncell\n:::\n:::\n:::caption\nTable caption\n:::\n::::\n";
18209        let doc = markdown_to_adf(md).unwrap();
18210        let table = &doc.content[0];
18211        assert_eq!(table.node_type, "table");
18212        let children = table.content.as_ref().unwrap();
18213        assert_eq!(children.len(), 2, "expected row + caption");
18214        assert_eq!(children[0].node_type, "tableRow");
18215        assert_eq!(children[1].node_type, "caption");
18216        let caption_content = children[1].content.as_ref().unwrap();
18217        assert_eq!(caption_content[0].text.as_deref(), Some("Table caption"));
18218    }
18219
18220    #[test]
18221    fn table_caption_round_trip_from_adf_json() {
18222        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
18223          {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
18224          {"type":"caption","content":[{"type":"text","text":"Table caption"}]}
18225        ]}]}"#;
18226        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18227        let md = adf_to_markdown(&doc).unwrap();
18228        assert!(md.contains("Table caption"), "caption text lost in ADF→JFM");
18229        let round_tripped = markdown_to_adf(&md).unwrap();
18230        let children = round_tripped.content[0].content.as_ref().unwrap();
18231        let caption = children.iter().find(|n| n.node_type == "caption");
18232        assert!(caption.is_some(), "caption lost on round-trip");
18233        let caption_text = caption.unwrap().content.as_ref().unwrap();
18234        assert_eq!(caption_text[0].text.as_deref(), Some("Table caption"));
18235    }
18236
18237    #[test]
18238    fn table_caption_with_inline_marks_round_trips() {
18239        let doc = AdfDocument {
18240            version: 1,
18241            doc_type: "doc".to_string(),
18242            content: vec![AdfNode::table(vec![
18243                AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
18244                    AdfNode::text("data"),
18245                ])])]),
18246                AdfNode::caption(vec![
18247                    AdfNode::text("Caption with "),
18248                    AdfNode::text_with_marks("bold", vec![AdfMark::strong()]),
18249                ]),
18250            ])],
18251        };
18252        let md = adf_to_markdown(&doc).unwrap();
18253        assert!(md.contains("**bold**"), "bold mark missing in caption");
18254        let round_tripped = markdown_to_adf(&md).unwrap();
18255        let caption = round_tripped.content[0]
18256            .content
18257            .as_ref()
18258            .unwrap()
18259            .iter()
18260            .find(|n| n.node_type == "caption")
18261            .expect("caption node missing after round-trip");
18262        let inlines = caption.content.as_ref().unwrap();
18263        let bold_node = inlines.iter().find(|n| {
18264            n.marks
18265                .as_ref()
18266                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
18267        });
18268        assert!(bold_node.is_some(), "bold mark lost in caption round-trip");
18269    }
18270
18271    // ── table caption localId tests (issue #524) ──────────────────────
18272
18273    #[test]
18274    fn table_caption_localid_roundtrip() {
18275        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
18276          {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
18277          {"type":"caption","attrs":{"localId":"abcdef123456"},"content":[{"type":"text","text":"Table with localId"}]}
18278        ]}]}"#;
18279        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18280        let md = adf_to_markdown(&doc).unwrap();
18281        assert!(
18282            md.contains("localId=abcdef123456"),
18283            "table caption localId should appear in markdown: {md}"
18284        );
18285        let rt = markdown_to_adf(&md).unwrap();
18286        let caption = rt.content[0]
18287            .content
18288            .as_ref()
18289            .unwrap()
18290            .iter()
18291            .find(|n| n.node_type == "caption")
18292            .expect("caption should survive round-trip");
18293        assert_eq!(
18294            caption.attrs.as_ref().unwrap()["localId"],
18295            "abcdef123456",
18296            "table caption localId should round-trip"
18297        );
18298    }
18299
18300    #[test]
18301    fn table_caption_without_localid_unchanged() {
18302        let md = "::::table\n:::tr\n:::td\ncell\n:::\n:::\n:::caption\nPlain caption\n:::\n::::\n";
18303        let doc = markdown_to_adf(md).unwrap();
18304        let caption = doc.content[0]
18305            .content
18306            .as_ref()
18307            .unwrap()
18308            .iter()
18309            .find(|n| n.node_type == "caption")
18310            .unwrap();
18311        assert!(
18312            caption.attrs.is_none(),
18313            "table caption without localId should not gain attrs"
18314        );
18315        let md2 = adf_to_markdown(&doc).unwrap();
18316        assert!(!md2.contains("localId"), "no localId should appear: {md2}");
18317    }
18318
18319    #[test]
18320    fn table_caption_localid_stripped_when_option_set() {
18321        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
18322          {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
18323          {"type":"caption","attrs":{"localId":"abcdef123456"},"content":[{"type":"text","text":"Stripped"}]}
18324        ]}]}"#;
18325        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18326        let opts = RenderOptions {
18327            strip_local_ids: true,
18328            ..Default::default()
18329        };
18330        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
18331        assert!(
18332            !md.contains("localId"),
18333            "table caption localId should be stripped: {md}"
18334        );
18335    }
18336
18337    #[test]
18338    #[test]
18339    fn tablecell_empty_attrs_preserved_on_roundtrip() {
18340        // Issue #385: tableCell with empty attrs:{} dropped on round-trip
18341        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"}]}]}]}]}]}"#;
18342        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18343        let md = adf_to_markdown(&doc).unwrap();
18344        let round_tripped = markdown_to_adf(&md).unwrap();
18345        let rows = round_tripped.content[0].content.as_ref().unwrap();
18346        let cell = &rows[0].content.as_ref().unwrap()[0];
18347        assert!(
18348            cell.attrs.is_some(),
18349            "tableCell attrs should be preserved, got None"
18350        );
18351        assert_eq!(
18352            cell.attrs.as_ref().unwrap(),
18353            &serde_json::json!({}),
18354            "tableCell attrs should be an empty object"
18355        );
18356    }
18357
18358    #[test]
18359    fn tablecell_empty_attrs_serialized_in_json() {
18360        // Issue #385: ensure the serialized JSON includes "attrs":{}
18361        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"}]}]}]}]}]}"#;
18362        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18363        let md = adf_to_markdown(&doc).unwrap();
18364        let round_tripped = markdown_to_adf(&md).unwrap();
18365        let json = serde_json::to_string(&round_tripped).unwrap();
18366        assert!(
18367            json.contains(r#""attrs":{}"#),
18368            "serialized JSON should contain \"attrs\":{{}}, got: {json}"
18369        );
18370    }
18371
18372    #[test]
18373    fn tablecell_empty_attrs_renders_braces_in_markdown() {
18374        // Issue #385: tableCell with empty attrs should render {} prefix in pipe tables
18375        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"}]}]}]}]}]}"#;
18376        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18377        let md = adf_to_markdown(&doc).unwrap();
18378        // Cell with attrs:{} should have {} prefix, cell without attrs should not
18379        assert!(
18380            md.contains("{} hello"),
18381            "cell with empty attrs should render '{{}} hello', got: {md}"
18382        );
18383        assert!(
18384            !md.contains("{} world"),
18385            "cell without attrs should not render '{{}}', got: {md}"
18386        );
18387    }
18388
18389    #[test]
18390    fn tablecell_no_attrs_unchanged_on_roundtrip() {
18391        // Ensure tableCell without attrs stays without attrs
18392        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"}]}]}]}]}]}"#;
18393        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18394        let md = adf_to_markdown(&doc).unwrap();
18395        let round_tripped = markdown_to_adf(&md).unwrap();
18396        let rows = round_tripped.content[0].content.as_ref().unwrap();
18397        let cell = &rows[0].content.as_ref().unwrap()[0];
18398        assert!(
18399            cell.attrs.is_none(),
18400            "tableCell without attrs should stay None, got: {:?}",
18401            cell.attrs
18402        );
18403    }
18404
18405    #[test]
18406    fn tablecell_nonempty_attrs_preserved_on_roundtrip() {
18407        // Ensure tableCell with non-empty attrs still works
18408        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"}]}]}]}]}]}"##;
18409        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18410        let md = adf_to_markdown(&doc).unwrap();
18411        let round_tripped = markdown_to_adf(&md).unwrap();
18412        let rows = round_tripped.content[0].content.as_ref().unwrap();
18413        let cell = &rows[1].content.as_ref().unwrap()[0];
18414        let attrs = cell.attrs.as_ref().unwrap();
18415        assert_eq!(attrs["background"], "#DEEBFF");
18416        assert_eq!(attrs["colspan"], 2);
18417    }
18418
18419    #[test]
18420    fn pipe_table_not_used_when_caption_present() {
18421        let doc = AdfDocument {
18422            version: 1,
18423            doc_type: "doc".to_string(),
18424            content: vec![AdfNode::table(vec![
18425                AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
18426                    AdfNode::text("H"),
18427                ])])]),
18428                AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
18429                    AdfNode::text("D"),
18430                ])])]),
18431                AdfNode::caption(vec![AdfNode::text("cap")]),
18432            ])],
18433        };
18434        let md = adf_to_markdown(&doc).unwrap();
18435        assert!(
18436            md.contains("::::table"),
18437            "pipe syntax should not be used when caption is present"
18438        );
18439    }
18440
18441    // ── Issue #402: ordered-list-like text in list item hardBreak ──
18442
18443    #[test]
18444    fn hardbreak_with_ordered_marker_in_bullet_item_roundtrips() {
18445        // Issue #402: text starting with "2. " after a hardBreak inside a
18446        // bullet list item must not be re-parsed as a new ordered list.
18447        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18448          {"type":"listItem","content":[{"type":"paragraph","content":[
18449            {"type":"text","text":"1. First item"},
18450            {"type":"hardBreak"},
18451            {"type":"text","text":"2. Honouring existing commitments"}
18452          ]}]}
18453        ]}]}"#;
18454        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18455        let md = adf_to_markdown(&doc).unwrap();
18456
18457        // The continuation line must be indented so it stays within the list item.
18458        assert!(
18459            md.contains("  2. Honouring"),
18460            "Continuation line should be indented, got:\n{md}"
18461        );
18462
18463        // Round-trip back to ADF
18464        let rt = markdown_to_adf(&md).unwrap();
18465        let list = &rt.content[0];
18466        assert_eq!(list.node_type, "bulletList");
18467        let items = list.content.as_ref().unwrap();
18468        assert_eq!(
18469            items.len(),
18470            1,
18471            "Should be one list item, got {}",
18472            items.len()
18473        );
18474
18475        let para = &items[0].content.as_ref().unwrap()[0];
18476        let inlines = para.content.as_ref().unwrap();
18477        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18478        assert_eq!(
18479            types,
18480            vec!["text", "hardBreak", "text"],
18481            "Expected text+hardBreak+text, got {types:?}"
18482        );
18483        assert_eq!(
18484            inlines[2].text.as_deref().unwrap(),
18485            "2. Honouring existing commitments"
18486        );
18487    }
18488
18489    #[test]
18490    fn hardbreak_with_ordered_marker_in_ordered_item_roundtrips() {
18491        // Same as above but inside an ordered list.
18492        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
18493          {"type":"listItem","content":[{"type":"paragraph","content":[
18494            {"type":"text","text":"Introduction  "},
18495            {"type":"hardBreak"},
18496            {"type":"text","text":"3. Third point"}
18497          ]}]}
18498        ]}]}"#;
18499        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18500        let md = adf_to_markdown(&doc).unwrap();
18501        let rt = markdown_to_adf(&md).unwrap();
18502
18503        let list = &rt.content[0];
18504        assert_eq!(list.node_type, "orderedList");
18505        let items = list.content.as_ref().unwrap();
18506        assert_eq!(items.len(), 1);
18507
18508        let para = &items[0].content.as_ref().unwrap()[0];
18509        let inlines = para.content.as_ref().unwrap();
18510        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18511        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18512        assert_eq!(inlines[2].text.as_deref().unwrap(), "3. Third point");
18513    }
18514
18515    #[test]
18516    fn hardbreak_with_bullet_marker_in_bullet_item_roundtrips() {
18517        // Text starting with "- " after a hardBreak must not become a nested bullet list.
18518        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18519          {"type":"listItem","content":[{"type":"paragraph","content":[
18520            {"type":"text","text":"Header  "},
18521            {"type":"hardBreak"},
18522            {"type":"text","text":"- not a sub-item"}
18523          ]}]}
18524        ]}]}"#;
18525        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18526        let md = adf_to_markdown(&doc).unwrap();
18527        let rt = markdown_to_adf(&md).unwrap();
18528
18529        let list = &rt.content[0];
18530        assert_eq!(list.node_type, "bulletList");
18531        let items = list.content.as_ref().unwrap();
18532        assert_eq!(
18533            items.len(),
18534            1,
18535            "Should be one list item, not {}",
18536            items.len()
18537        );
18538
18539        let para = &items[0].content.as_ref().unwrap()[0];
18540        let inlines = para.content.as_ref().unwrap();
18541        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18542        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18543        assert_eq!(inlines[2].text.as_deref().unwrap(), "- not a sub-item");
18544    }
18545
18546    #[test]
18547    fn hardbreak_continuation_followed_by_sub_list() {
18548        // A hardBreak continuation line followed by a real sub-list.
18549        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18550          {"type":"listItem","content":[
18551            {"type":"paragraph","content":[
18552              {"type":"text","text":"Main item  "},
18553              {"type":"hardBreak"},
18554              {"type":"text","text":"continued here"}
18555            ]},
18556            {"type":"bulletList","content":[
18557              {"type":"listItem","content":[{"type":"paragraph","content":[
18558                {"type":"text","text":"sub-item"}
18559              ]}]}
18560            ]}
18561          ]}
18562        ]}]}"#;
18563        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18564        let md = adf_to_markdown(&doc).unwrap();
18565        let rt = markdown_to_adf(&md).unwrap();
18566
18567        let list = &rt.content[0];
18568        let items = list.content.as_ref().unwrap();
18569        assert_eq!(items.len(), 1);
18570
18571        let item_content = items[0].content.as_ref().unwrap();
18572        assert_eq!(item_content.len(), 2, "Expected paragraph + nested list");
18573        assert_eq!(item_content[0].node_type, "paragraph");
18574        assert_eq!(item_content[1].node_type, "bulletList");
18575
18576        // Check the paragraph has hardBreak
18577        let inlines = item_content[0].content.as_ref().unwrap();
18578        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18579        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18580    }
18581
18582    #[test]
18583    fn multiple_hardbreaks_with_numbered_text_roundtrip() {
18584        // Multiple hardBreaks where each continuation resembles an ordered list.
18585        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18586          {"type":"listItem","content":[{"type":"paragraph","content":[
18587            {"type":"text","text":"Preamble  "},
18588            {"type":"hardBreak"},
18589            {"type":"text","text":"1. Alpha  "},
18590            {"type":"hardBreak"},
18591            {"type":"text","text":"2. Bravo"}
18592          ]}]}
18593        ]}]}"#;
18594        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18595        let md = adf_to_markdown(&doc).unwrap();
18596        let rt = markdown_to_adf(&md).unwrap();
18597
18598        let items = rt.content[0].content.as_ref().unwrap();
18599        assert_eq!(items.len(), 1);
18600
18601        let inlines = items[0].content.as_ref().unwrap()[0]
18602            .content
18603            .as_ref()
18604            .unwrap();
18605        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18606        assert_eq!(
18607            types,
18608            vec!["text", "hardBreak", "text", "hardBreak", "text"]
18609        );
18610    }
18611
18612    #[test]
18613    fn trailing_hardbreak_in_bullet_item_roundtrips() {
18614        // A hardBreak as the last inline node with no text after it.
18615        // Exercises the `break` path in the continuation loop and the
18616        // empty-line rendering branch.
18617        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18618          {"type":"listItem","content":[{"type":"paragraph","content":[
18619            {"type":"text","text":"ends with break"},
18620            {"type":"hardBreak"}
18621          ]}]}
18622        ]}]}"#;
18623        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18624        let md = adf_to_markdown(&doc).unwrap();
18625        let rt = markdown_to_adf(&md).unwrap();
18626
18627        let list = &rt.content[0];
18628        assert_eq!(list.node_type, "bulletList");
18629        let inlines = list.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0]
18630            .content
18631            .as_ref()
18632            .unwrap();
18633        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18634        assert_eq!(types, vec!["text", "hardBreak"]);
18635    }
18636
18637    #[test]
18638    fn trailing_hardbreak_in_ordered_item_roundtrips() {
18639        // Same as above but in an ordered list, covering the ordered-list
18640        // continuation `break` path.
18641        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
18642          {"type":"listItem","content":[{"type":"paragraph","content":[
18643            {"type":"text","text":"ends with break"},
18644            {"type":"hardBreak"}
18645          ]}]}
18646        ]}]}"#;
18647        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18648        let md = adf_to_markdown(&doc).unwrap();
18649        let rt = markdown_to_adf(&md).unwrap();
18650
18651        let list = &rt.content[0];
18652        assert_eq!(list.node_type, "orderedList");
18653        let inlines = list.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0]
18654            .content
18655            .as_ref()
18656            .unwrap();
18657        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18658        assert_eq!(types, vec!["text", "hardBreak"]);
18659    }
18660
18661    #[test]
18662    fn trailing_space_hardbreak_continuation_in_bullet_item() {
18663        // Exercises the `ends_with("  ")` path in `has_trailing_hard_break`
18664        // by parsing hand-written markdown that uses trailing-space style
18665        // hardBreaks instead of backslash style.
18666        let md = "- first line  \n  2. continued\n";
18667        let doc = markdown_to_adf(md).unwrap();
18668
18669        let list = &doc.content[0];
18670        assert_eq!(list.node_type, "bulletList");
18671        let items = list.content.as_ref().unwrap();
18672        assert_eq!(
18673            items.len(),
18674            1,
18675            "Should be one list item, got {}",
18676            items.len()
18677        );
18678
18679        let para = &items[0].content.as_ref().unwrap()[0];
18680        let inlines = para.content.as_ref().unwrap();
18681        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18682        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18683        assert_eq!(inlines[2].text.as_deref().unwrap(), "2. continued");
18684    }
18685
18686    #[test]
18687    fn trailing_space_hardbreak_continuation_in_ordered_item() {
18688        // Same as above but for ordered list, exercising the trailing-space
18689        // path in the ordered-list continuation loop.
18690        let md = "1. first line  \n  - continued\n";
18691        let doc = markdown_to_adf(md).unwrap();
18692
18693        let list = &doc.content[0];
18694        assert_eq!(list.node_type, "orderedList");
18695        let items = list.content.as_ref().unwrap();
18696        assert_eq!(
18697            items.len(),
18698            1,
18699            "Should be one list item, got {}",
18700            items.len()
18701        );
18702
18703        let para = &items[0].content.as_ref().unwrap()[0];
18704        let inlines = para.content.as_ref().unwrap();
18705        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18706        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18707        assert_eq!(inlines[2].text.as_deref().unwrap(), "- continued");
18708    }
18709
18710    #[test]
18711    fn multi_paragraph_list_item_with_ordered_marker_roundtrips() {
18712        // Issue #402 comment: a listItem with a second paragraph starting
18713        // with "2. " must not become a separate orderedList.
18714        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18715          {"type":"listItem","content":[
18716            {"type":"paragraph","content":[{"type":"text","text":"some preamble"}]},
18717            {"type":"paragraph","content":[{"type":"text","text":"2. Honouring existing commitments"}]}
18718          ]}
18719        ]}]}"#;
18720        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18721        let md = adf_to_markdown(&doc).unwrap();
18722        let rt = markdown_to_adf(&md).unwrap();
18723
18724        assert_eq!(rt.content.len(), 1, "Should be one top-level block");
18725        let list = &rt.content[0];
18726        assert_eq!(list.node_type, "bulletList");
18727        let items = list.content.as_ref().unwrap();
18728        assert_eq!(items.len(), 1);
18729        let item_content = items[0].content.as_ref().unwrap();
18730        assert_eq!(
18731            item_content.len(),
18732            2,
18733            "Expected 2 paragraphs inside the list item, got {}",
18734            item_content.len()
18735        );
18736        assert_eq!(item_content[0].node_type, "paragraph");
18737        assert_eq!(item_content[1].node_type, "paragraph");
18738        let text = item_content[1].content.as_ref().unwrap()[0]
18739            .text
18740            .as_deref()
18741            .unwrap();
18742        assert_eq!(text, "2. Honouring existing commitments");
18743    }
18744
18745    #[test]
18746    fn multi_paragraph_list_item_with_bullet_marker_roundtrips() {
18747        // Paragraph starting with "- " inside a list item.
18748        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18749          {"type":"listItem","content":[
18750            {"type":"paragraph","content":[{"type":"text","text":"preamble"}]},
18751            {"type":"paragraph","content":[{"type":"text","text":"- not a sub-item"}]}
18752          ]}
18753        ]}]}"#;
18754        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18755        let md = adf_to_markdown(&doc).unwrap();
18756        let rt = markdown_to_adf(&md).unwrap();
18757
18758        let items = rt.content[0].content.as_ref().unwrap();
18759        assert_eq!(items.len(), 1);
18760        let item_content = items[0].content.as_ref().unwrap();
18761        assert_eq!(item_content.len(), 2);
18762        assert_eq!(item_content[1].node_type, "paragraph");
18763        let text = item_content[1].content.as_ref().unwrap()[0]
18764            .text
18765            .as_deref()
18766            .unwrap();
18767        assert_eq!(text, "- not a sub-item");
18768    }
18769
18770    #[test]
18771    fn backslash_escape_in_inline_text() {
18772        // Verify that `\. ` is unescaped to `. ` in inline parsing.
18773        let nodes = parse_inline(r"2\. text");
18774        assert_eq!(nodes.len(), 1, "Should be one text node");
18775        assert_eq!(nodes[0].text.as_deref().unwrap(), "2. text");
18776    }
18777
18778    #[test]
18779    fn escape_list_marker_ordered() {
18780        assert_eq!(escape_list_marker("2. text"), r"2\. text");
18781        assert_eq!(escape_list_marker("10. tenth"), r"10\. tenth");
18782    }
18783
18784    #[test]
18785    fn escape_list_marker_bullet() {
18786        assert_eq!(escape_list_marker("- text"), r"\- text");
18787        assert_eq!(escape_list_marker("* text"), r"\* text");
18788        assert_eq!(escape_list_marker("+ text"), r"\+ text");
18789    }
18790
18791    #[test]
18792    fn escape_list_marker_plain() {
18793        assert_eq!(escape_list_marker("plain text"), "plain text");
18794        assert_eq!(escape_list_marker("no. marker"), "no. marker");
18795    }
18796
18797    #[test]
18798    fn escape_emoji_shortcodes_basic() {
18799        assert_eq!(escape_emoji_shortcodes(":fire:"), r"\:fire:");
18800        assert_eq!(
18801            escape_emoji_shortcodes("hello :wave: world"),
18802            r"hello \:wave: world"
18803        );
18804    }
18805
18806    #[test]
18807    fn escape_emoji_shortcodes_double_colon() {
18808        // Only the colon that starts `:Active:` needs escaping
18809        assert_eq!(
18810            escape_emoji_shortcodes("Status::Active::Running"),
18811            r"Status:\:Active::Running"
18812        );
18813    }
18814
18815    #[test]
18816    fn escape_emoji_shortcodes_no_match() {
18817        // Lone colons, numeric-only between colons like 10:30
18818        assert_eq!(escape_emoji_shortcodes("Time is 10:30"), "Time is 10:30");
18819        assert_eq!(escape_emoji_shortcodes("no colons here"), "no colons here");
18820        assert_eq!(escape_emoji_shortcodes("trailing:"), "trailing:");
18821        assert_eq!(escape_emoji_shortcodes(":"), ":");
18822    }
18823
18824    #[test]
18825    fn escape_emoji_shortcodes_mixed() {
18826        assert_eq!(
18827            escape_emoji_shortcodes("Alert :fire: on pod:pod42"),
18828            r"Alert \:fire: on pod:pod42"
18829        );
18830    }
18831
18832    #[test]
18833    fn escape_emoji_shortcodes_unicode() {
18834        // Issue #552: Unicode alphanumeric chars must be escaped to match
18835        // `try_parse_emoji_shortcode`, which uses `is_alphanumeric` (not the
18836        // ASCII-only variant).  Without this, `:Café:` rendered un-escaped
18837        // would be re-parsed as an emoji on round-trip.
18838        assert_eq!(escape_emoji_shortcodes(":Café:"), r"\:Café:");
18839        assert_eq!(escape_emoji_shortcodes(":über:"), r"\:über:");
18840        assert_eq!(escape_emoji_shortcodes(":配置:"), r"\:配置:");
18841        assert_eq!(
18842            escape_emoji_shortcodes("ZBC::配置::Production"),
18843            r"ZBC:\:配置::Production"
18844        );
18845    }
18846
18847    #[test]
18848    fn escape_emoji_shortcodes_mixed_script_name() {
18849        // Issue #552: A name that mixes ASCII and Unicode alphanumerics is
18850        // still a single valid shortcode under `is_alphanumeric`.
18851        assert_eq!(escape_emoji_shortcodes(":abc配置:"), r"\:abc配置:");
18852        assert_eq!(escape_emoji_shortcodes(":配置abc:"), r"\:配置abc:");
18853    }
18854
18855    #[test]
18856    fn escape_emoji_shortcodes_unicode_followed_by_non_colon() {
18857        // `:Café world:` — `Café` is alphanumeric but the terminator is a
18858        // space, not `:`, so the `after + name_end < text.len()` path is
18859        // exercised but the final `== b':'` check bails out and nothing
18860        // gets escaped.  Guards the negative branch of the predicate.
18861        assert_eq!(escape_emoji_shortcodes(":Café world:"), ":Café world:");
18862    }
18863
18864    #[test]
18865    fn escape_emoji_shortcodes_name_runs_to_end() {
18866        // Colon followed by alphanumerics to end of string: `.find(...)` returns
18867        // `None`, so `name_end` falls back to the full remaining length via
18868        // `map_or`.  The `after + name_end < text.len()` check then fails and
18869        // nothing is escaped.  Exercises the `map_or` default branch for both
18870        // ASCII and Unicode names.
18871        assert_eq!(escape_emoji_shortcodes(":abc"), ":abc");
18872        assert_eq!(escape_emoji_shortcodes(":配置"), ":配置");
18873    }
18874
18875    #[test]
18876    fn unicode_shortcode_pattern_text_round_trips_as_text() {
18877        // Issue #552: A text node containing `:Café:` must round-trip as text,
18878        // not be split into text + emoji nodes.
18879        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18880          {"type":"text","text":"Visit :Café: today"}
18881        ]}]}"#;
18882        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18883
18884        let md = adf_to_markdown(&doc).unwrap();
18885        let round_tripped = markdown_to_adf(&md).unwrap();
18886        let content = round_tripped.content[0].content.as_ref().unwrap();
18887
18888        assert_eq!(
18889            content.len(),
18890            1,
18891            "should be a single text node, got: {content:?}"
18892        );
18893        assert_eq!(content[0].node_type, "text");
18894        assert_eq!(content[0].text.as_deref().unwrap(), "Visit :Café: today");
18895    }
18896
18897    #[test]
18898    fn unicode_double_colon_pattern_text_round_trips() {
18899        // Issue #552: `ZBC::配置::Production` should round-trip without splitting.
18900        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18901          {"type":"text","text":"Use ZBC::配置::Production for prod"}
18902        ]}]}"#;
18903        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18904
18905        let md = adf_to_markdown(&doc).unwrap();
18906        let round_tripped = markdown_to_adf(&md).unwrap();
18907        let content = round_tripped.content[0].content.as_ref().unwrap();
18908
18909        assert_eq!(
18910            content.len(),
18911            1,
18912            "should be a single text node, got: {content:?}"
18913        );
18914        assert_eq!(
18915            content[0].text.as_deref().unwrap(),
18916            "Use ZBC::配置::Production for prod"
18917        );
18918    }
18919
18920    #[test]
18921    fn merge_adjacent_text_nodes() {
18922        let mut nodes = vec![AdfNode::text("a"), AdfNode::text("b"), AdfNode::text("c")];
18923        merge_adjacent_text(&mut nodes);
18924        assert_eq!(nodes.len(), 1);
18925        assert_eq!(nodes[0].text.as_deref().unwrap(), "abc");
18926    }
18927
18928    // ── Issue #455: text after hardBreak in paragraph re-parsed as list ──
18929
18930    #[test]
18931    fn issue_455_paragraph_hardbreak_ordered_marker_roundtrips() {
18932        // Issue #455: "1. text" after a hardBreak in a paragraph must not
18933        // become an ordered list.
18934        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18935          {"type":"text","text":"Introduction: "},
18936          {"type":"hardBreak"},
18937          {"type":"text","text":"1. This text follows a hardBreak"}
18938        ]}]}"#;
18939        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18940        let md = adf_to_markdown(&doc).unwrap();
18941        let rt = markdown_to_adf(&md).unwrap();
18942
18943        assert_eq!(rt.content.len(), 1, "Should remain one block");
18944        assert_eq!(rt.content[0].node_type, "paragraph");
18945        let inlines = rt.content[0].content.as_ref().unwrap();
18946        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18947        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18948        assert_eq!(
18949            inlines[2].text.as_deref(),
18950            Some("1. This text follows a hardBreak")
18951        );
18952    }
18953
18954    #[test]
18955    fn issue_455_paragraph_hardbreak_bullet_marker_roundtrips() {
18956        // Issue #455 variant: "- text" after a hardBreak in a paragraph.
18957        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18958          {"type":"text","text":"Intro"},
18959          {"type":"hardBreak"},
18960          {"type":"text","text":"- not a list item"}
18961        ]}]}"#;
18962        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18963        let md = adf_to_markdown(&doc).unwrap();
18964        let rt = markdown_to_adf(&md).unwrap();
18965
18966        assert_eq!(rt.content.len(), 1);
18967        assert_eq!(rt.content[0].node_type, "paragraph");
18968        let inlines = rt.content[0].content.as_ref().unwrap();
18969        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18970        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18971        assert_eq!(inlines[2].text.as_deref(), Some("- not a list item"));
18972    }
18973
18974    #[test]
18975    fn issue_455_paragraph_hardbreak_heading_marker_roundtrips() {
18976        // Issue #455 variant: "# text" after a hardBreak in a paragraph.
18977        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18978          {"type":"text","text":"Intro"},
18979          {"type":"hardBreak"},
18980          {"type":"text","text":"# not a heading"}
18981        ]}]}"##;
18982        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18983        let md = adf_to_markdown(&doc).unwrap();
18984        let rt = markdown_to_adf(&md).unwrap();
18985
18986        assert_eq!(rt.content.len(), 1);
18987        assert_eq!(rt.content[0].node_type, "paragraph");
18988        let inlines = rt.content[0].content.as_ref().unwrap();
18989        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18990        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18991        assert_eq!(inlines[2].text.as_deref(), Some("# not a heading"));
18992    }
18993
18994    #[test]
18995    fn issue_455_paragraph_hardbreak_blockquote_marker_roundtrips() {
18996        // Issue #455 variant: "> text" after a hardBreak in a paragraph.
18997        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18998          {"type":"text","text":"Intro"},
18999          {"type":"hardBreak"},
19000          {"type":"text","text":"> not a blockquote"}
19001        ]}]}"#;
19002        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19003        let md = adf_to_markdown(&doc).unwrap();
19004        let rt = markdown_to_adf(&md).unwrap();
19005
19006        assert_eq!(rt.content.len(), 1);
19007        assert_eq!(rt.content[0].node_type, "paragraph");
19008        let inlines = rt.content[0].content.as_ref().unwrap();
19009        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19010        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19011        assert_eq!(inlines[2].text.as_deref(), Some("> not a blockquote"));
19012    }
19013
19014    #[test]
19015    fn issue_455_paragraph_multiple_hardbreaks_with_ordered_markers() {
19016        // Multiple hardBreaks in a paragraph, each followed by "N. text".
19017        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19018          {"type":"text","text":"Preamble"},
19019          {"type":"hardBreak"},
19020          {"type":"text","text":"1. First"},
19021          {"type":"hardBreak"},
19022          {"type":"text","text":"2. Second"},
19023          {"type":"hardBreak"},
19024          {"type":"text","text":"3. Third"}
19025        ]}]}"#;
19026        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19027        let md = adf_to_markdown(&doc).unwrap();
19028        let rt = markdown_to_adf(&md).unwrap();
19029
19030        assert_eq!(rt.content.len(), 1);
19031        assert_eq!(rt.content[0].node_type, "paragraph");
19032        let inlines = rt.content[0].content.as_ref().unwrap();
19033        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19034        assert_eq!(
19035            types,
19036            vec![
19037                "text",
19038                "hardBreak",
19039                "text",
19040                "hardBreak",
19041                "text",
19042                "hardBreak",
19043                "text"
19044            ]
19045        );
19046        assert_eq!(inlines[2].text.as_deref(), Some("1. First"));
19047        assert_eq!(inlines[4].text.as_deref(), Some("2. Second"));
19048        assert_eq!(inlines[6].text.as_deref(), Some("3. Third"));
19049    }
19050
19051    #[test]
19052    fn issue_455_paragraph_hardbreak_jfm_indentation() {
19053        // Verify that ADF→JFM output indents continuation lines.
19054        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19055          {"type":"text","text":"Intro"},
19056          {"type":"hardBreak"},
19057          {"type":"text","text":"1. continued"}
19058        ]}]}"#;
19059        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19060        let md = adf_to_markdown(&doc).unwrap();
19061        assert!(
19062            md.contains("Intro\\\n  1. continued"),
19063            "Continuation should be 2-space-indented, got: {md:?}"
19064        );
19065    }
19066
19067    #[test]
19068    fn issue_455_paragraph_hardbreak_from_jfm() {
19069        // Verify that JFM with 2-space-indented continuation is parsed
19070        // back as a single paragraph with hardBreak.
19071        let md = "Intro\\\n  1. This is continuation text\n";
19072        let doc = markdown_to_adf(md).unwrap();
19073
19074        assert_eq!(doc.content.len(), 1);
19075        assert_eq!(doc.content[0].node_type, "paragraph");
19076        let inlines = doc.content[0].content.as_ref().unwrap();
19077        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19078        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19079        assert_eq!(
19080            inlines[2].text.as_deref(),
19081            Some("1. This is continuation text")
19082        );
19083    }
19084
19085    #[test]
19086    fn issue_455_paragraph_starts_with_ordered_marker_and_hardbreak() {
19087        // Coverage: first line IS a list marker AND paragraph has hardBreaks.
19088        // Exercises the escape_list_marker path on the first line of a
19089        // multi-line paragraph buf in the rendering code.
19090        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19091          {"type":"text","text":"1. Starting with a number"},
19092          {"type":"hardBreak"},
19093          {"type":"text","text":"continuation after break"}
19094        ]}]}"#;
19095        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19096        let md = adf_to_markdown(&doc).unwrap();
19097        // First line should be escaped so it's not parsed as ordered list
19098        assert!(
19099            md.contains(r"1\. Starting with a number"),
19100            "First line should have escaped list marker, got: {md:?}"
19101        );
19102        let rt = markdown_to_adf(&md).unwrap();
19103
19104        assert_eq!(rt.content.len(), 1);
19105        assert_eq!(rt.content[0].node_type, "paragraph");
19106        let inlines = rt.content[0].content.as_ref().unwrap();
19107        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19108        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19109        assert_eq!(
19110            inlines[0].text.as_deref(),
19111            Some("1. Starting with a number")
19112        );
19113        assert_eq!(inlines[2].text.as_deref(), Some("continuation after break"));
19114    }
19115
19116    #[test]
19117    fn ordered_marker_paragraph_in_table_cell_roundtrips() {
19118        // Issue #402: paragraph with "2. " text inside a tableCell must
19119        // not be re-parsed as an ordered list.
19120        let adf_json = r#"{"version":1,"type":"doc","content":[{
19121          "type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},
19122          "content":[{"type":"tableRow","content":[{
19123            "type":"tableCell","attrs":{"colspan":1,"rowspan":1},
19124            "content":[{"type":"paragraph","content":[
19125              {"type":"text","text":"2. Honouring existing commitments"}
19126            ]}]
19127          }]}]
19128        }]}"#;
19129        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19130        let md = adf_to_markdown(&doc).unwrap();
19131        let rt = markdown_to_adf(&md).unwrap();
19132
19133        let table = &rt.content[0];
19134        let cell = &table.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0];
19135        let para = &cell.content.as_ref().unwrap()[0];
19136        assert_eq!(para.node_type, "paragraph");
19137        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
19138        assert_eq!(text, "2. Honouring existing commitments");
19139    }
19140
19141    #[test]
19142    fn bullet_marker_paragraph_standalone_roundtrips() {
19143        // A top-level paragraph starting with "- " must round-trip as
19144        // a paragraph, not a bullet list.
19145        let adf_json = r#"{"version":1,"type":"doc","content":[
19146          {"type":"paragraph","content":[
19147            {"type":"text","text":"- not a list item"}
19148          ]}
19149        ]}"#;
19150        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19151        let md = adf_to_markdown(&doc).unwrap();
19152        assert!(
19153            md.contains(r"\- not a list item"),
19154            "Should escape the leading dash, got:\n{md}"
19155        );
19156        let rt = markdown_to_adf(&md).unwrap();
19157        assert_eq!(rt.content[0].node_type, "paragraph");
19158        let text = rt.content[0].content.as_ref().unwrap()[0]
19159            .text
19160            .as_deref()
19161            .unwrap();
19162        assert_eq!(text, "- not a list item");
19163    }
19164
19165    #[test]
19166    fn merge_adjacent_text_skips_non_text_nodes() {
19167        // Exercises the `else { i += 1 }` branch when adjacent nodes
19168        // are not both plain text.
19169        let mut nodes = vec![
19170            AdfNode::text("a"),
19171            AdfNode::hard_break(),
19172            AdfNode::text("b"),
19173        ];
19174        merge_adjacent_text(&mut nodes);
19175        assert_eq!(nodes.len(), 3);
19176    }
19177
19178    #[test]
19179    fn star_bullet_paragraph_roundtrips() {
19180        // Paragraph starting with "* " must round-trip without becoming
19181        // a bullet list.
19182        let adf_json = r#"{"version":1,"type":"doc","content":[
19183          {"type":"paragraph","content":[
19184            {"type":"text","text":"* starred"}
19185          ]}
19186        ]}"#;
19187        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19188        let md = adf_to_markdown(&doc).unwrap();
19189        let rt = markdown_to_adf(&md).unwrap();
19190        assert_eq!(rt.content[0].node_type, "paragraph");
19191        assert_eq!(
19192            rt.content[0].content.as_ref().unwrap()[0]
19193                .text
19194                .as_deref()
19195                .unwrap(),
19196            "* starred"
19197        );
19198    }
19199
19200    // ---- Issue #388 tests ----
19201
19202    #[test]
19203    fn issue_388_ordered_list_with_strong_hardbreak_roundtrips() {
19204        // Issue #388: orderedList with 2 listItems, each containing
19205        // strong-marked text + hardBreak + plain text.
19206        let adf_json = r#"{"version":1,"type":"doc","content":[
19207          {"type":"orderedList","attrs":{"order":1},"content":[
19208            {"type":"listItem","content":[
19209              {"type":"paragraph","content":[
19210                {"type":"text","text":"Bold heading","marks":[{"type":"strong"}]},
19211                {"type":"hardBreak"},
19212                {"type":"text","text":"Content after break"}
19213              ]}
19214            ]},
19215            {"type":"listItem","content":[
19216              {"type":"paragraph","content":[
19217                {"type":"text","text":"Second item","marks":[{"type":"strong"}]},
19218                {"type":"hardBreak"},
19219                {"type":"text","text":"More content"}
19220              ]}
19221            ]}
19222          ]}
19223        ]}"#;
19224        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19225        let md = adf_to_markdown(&doc).unwrap();
19226        let rt = markdown_to_adf(&md).unwrap();
19227
19228        // Must remain a single orderedList
19229        assert_eq!(
19230            rt.content.len(),
19231            1,
19232            "Should be 1 block (orderedList), got {}",
19233            rt.content.len()
19234        );
19235        assert_eq!(rt.content[0].node_type, "orderedList");
19236        let items = rt.content[0].content.as_ref().unwrap();
19237        assert_eq!(
19238            items.len(),
19239            2,
19240            "Should have 2 listItems, got {}",
19241            items.len()
19242        );
19243
19244        // First item: text(strong) + hardBreak + text
19245        let p1 = items[0].content.as_ref().unwrap()[0]
19246            .content
19247            .as_ref()
19248            .unwrap();
19249        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
19250        assert_eq!(types1, vec!["text", "hardBreak", "text"]);
19251        assert_eq!(p1[0].text.as_deref(), Some("Bold heading"));
19252        assert_eq!(p1[2].text.as_deref(), Some("Content after break"));
19253
19254        // Second item: text(strong) + hardBreak + text
19255        let p2 = items[1].content.as_ref().unwrap()[0]
19256            .content
19257            .as_ref()
19258            .unwrap();
19259        let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
19260        assert_eq!(types2, vec!["text", "hardBreak", "text"]);
19261        assert_eq!(p2[0].text.as_deref(), Some("Second item"));
19262        assert_eq!(p2[2].text.as_deref(), Some("More content"));
19263    }
19264
19265    #[test]
19266    fn issue_388_bullet_list_with_strong_hardbreak_roundtrips() {
19267        // Bullet list variant of issue #388.
19268        let adf_json = r#"{"version":1,"type":"doc","content":[
19269          {"type":"bulletList","content":[
19270            {"type":"listItem","content":[
19271              {"type":"paragraph","content":[
19272                {"type":"text","text":"First","marks":[{"type":"strong"}]},
19273                {"type":"hardBreak"},
19274                {"type":"text","text":"details"}
19275              ]}
19276            ]},
19277            {"type":"listItem","content":[
19278              {"type":"paragraph","content":[
19279                {"type":"text","text":"Second","marks":[{"type":"em"}]},
19280                {"type":"hardBreak"},
19281                {"type":"text","text":"more details"}
19282              ]}
19283            ]}
19284          ]}
19285        ]}"#;
19286        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19287        let md = adf_to_markdown(&doc).unwrap();
19288        let rt = markdown_to_adf(&md).unwrap();
19289
19290        assert_eq!(rt.content.len(), 1);
19291        assert_eq!(rt.content[0].node_type, "bulletList");
19292        let items = rt.content[0].content.as_ref().unwrap();
19293        assert_eq!(items.len(), 2);
19294
19295        let p1 = items[0].content.as_ref().unwrap()[0]
19296            .content
19297            .as_ref()
19298            .unwrap();
19299        assert_eq!(p1[0].text.as_deref(), Some("First"));
19300        assert_eq!(p1[2].text.as_deref(), Some("details"));
19301
19302        let p2 = items[1].content.as_ref().unwrap()[0]
19303            .content
19304            .as_ref()
19305            .unwrap();
19306        assert_eq!(p2[0].text.as_deref(), Some("Second"));
19307        assert_eq!(p2[2].text.as_deref(), Some("more details"));
19308    }
19309
19310    #[test]
19311    fn issue_388_ordered_list_hardbreak_jfm_indentation() {
19312        // Verify the JFM output has properly indented continuation lines.
19313        let adf_json = r#"{"version":1,"type":"doc","content":[
19314          {"type":"orderedList","attrs":{"order":1},"content":[
19315            {"type":"listItem","content":[
19316              {"type":"paragraph","content":[
19317                {"type":"text","text":"heading","marks":[{"type":"strong"}]},
19318                {"type":"hardBreak"},
19319                {"type":"text","text":"body"}
19320              ]}
19321            ]}
19322          ]}
19323        ]}"#;
19324        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19325        let md = adf_to_markdown(&doc).unwrap();
19326        assert!(
19327            md.contains("1. **heading**\\\n  body"),
19328            "Continuation should be indented, got:\n{md}"
19329        );
19330    }
19331
19332    #[test]
19333    fn issue_388_ordered_list_hardbreak_from_jfm() {
19334        // Direct JFM → ADF: ordered list with hardBreak continuation.
19335        let md = "1. **bold**\\\n  continued\n2. **also bold**\\\n  also continued\n";
19336        let doc = markdown_to_adf(md).unwrap();
19337
19338        assert_eq!(doc.content.len(), 1);
19339        assert_eq!(doc.content[0].node_type, "orderedList");
19340        let items = doc.content[0].content.as_ref().unwrap();
19341        assert_eq!(items.len(), 2);
19342
19343        let p1 = items[0].content.as_ref().unwrap()[0]
19344            .content
19345            .as_ref()
19346            .unwrap();
19347        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
19348        assert_eq!(types1, vec!["text", "hardBreak", "text"]);
19349        assert_eq!(p1[0].text.as_deref(), Some("bold"));
19350        assert_eq!(p1[2].text.as_deref(), Some("continued"));
19351
19352        let p2 = items[1].content.as_ref().unwrap()[0]
19353            .content
19354            .as_ref()
19355            .unwrap();
19356        let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
19357        assert_eq!(types2, vec!["text", "hardBreak", "text"]);
19358    }
19359
19360    #[test]
19361    fn issue_388_bullet_list_hardbreak_from_jfm() {
19362        // Direct JFM → ADF: bullet list with hardBreak continuation.
19363        let md = "- first\\\n  second\n- third\\\n  fourth\n";
19364        let doc = markdown_to_adf(md).unwrap();
19365
19366        assert_eq!(doc.content.len(), 1);
19367        assert_eq!(doc.content[0].node_type, "bulletList");
19368        let items = doc.content[0].content.as_ref().unwrap();
19369        assert_eq!(items.len(), 2);
19370
19371        for (i, expected) in [("first", "second"), ("third", "fourth")]
19372            .iter()
19373            .enumerate()
19374        {
19375            let p = items[i].content.as_ref().unwrap()[0]
19376                .content
19377                .as_ref()
19378                .unwrap();
19379            let types: Vec<&str> = p.iter().map(|n| n.node_type.as_str()).collect();
19380            assert_eq!(types, vec!["text", "hardBreak", "text"]);
19381            assert_eq!(p[0].text.as_deref(), Some(expected.0));
19382            assert_eq!(p[2].text.as_deref(), Some(expected.1));
19383        }
19384    }
19385
19386    #[test]
19387    fn issue_433_heading_hardbreak_roundtrips() {
19388        // Issue #433: hardBreak inside heading splits into heading + paragraph.
19389        let adf_json = r#"{"version":1,"type":"doc","content":[{
19390          "type":"heading",
19391          "attrs":{"level":1},
19392          "content":[
19393            {"type":"text","text":"Line one"},
19394            {"type":"hardBreak"},
19395            {"type":"text","text":"Line two"}
19396          ]
19397        }]}"#;
19398        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19399        let md = adf_to_markdown(&doc).unwrap();
19400        let rt = markdown_to_adf(&md).unwrap();
19401
19402        assert_eq!(
19403            rt.content.len(),
19404            1,
19405            "Should remain a single heading, got {} blocks",
19406            rt.content.len()
19407        );
19408        assert_eq!(rt.content[0].node_type, "heading");
19409        let inlines = rt.content[0].content.as_ref().unwrap();
19410        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19411        assert_eq!(
19412            types,
19413            vec!["text", "hardBreak", "text"],
19414            "hardBreak should be preserved, got: {types:?}"
19415        );
19416        assert_eq!(inlines[0].text.as_deref(), Some("Line one"));
19417        assert_eq!(inlines[2].text.as_deref(), Some("Line two"));
19418    }
19419
19420    #[test]
19421    fn issue_433_heading_hardbreak_jfm_indentation() {
19422        // Verify the JFM output has properly indented continuation lines.
19423        let adf_json = r#"{"version":1,"type":"doc","content":[{
19424          "type":"heading",
19425          "attrs":{"level":2},
19426          "content":[
19427            {"type":"text","text":"Title"},
19428            {"type":"hardBreak"},
19429            {"type":"text","text":"Subtitle"}
19430          ]
19431        }]}"#;
19432        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19433        let md = adf_to_markdown(&doc).unwrap();
19434        assert!(
19435            md.contains("## Title\\\n  Subtitle"),
19436            "Continuation should be indented, got:\n{md}"
19437        );
19438    }
19439
19440    #[test]
19441    fn issue_433_heading_hardbreak_from_jfm() {
19442        // Direct JFM → ADF: heading with hardBreak continuation.
19443        let md = "# First\\\n  Second\n";
19444        let doc = markdown_to_adf(md).unwrap();
19445
19446        assert_eq!(doc.content.len(), 1);
19447        assert_eq!(doc.content[0].node_type, "heading");
19448        let inlines = doc.content[0].content.as_ref().unwrap();
19449        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19450        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19451        assert_eq!(inlines[0].text.as_deref(), Some("First"));
19452        assert_eq!(inlines[2].text.as_deref(), Some("Second"));
19453    }
19454
19455    #[test]
19456    fn issue_433_heading_consecutive_hardbreaks_roundtrip() {
19457        // Consecutive hardBreaks in a heading.
19458        let adf_json = r#"{"version":1,"type":"doc","content":[{
19459          "type":"heading",
19460          "attrs":{"level":3},
19461          "content":[
19462            {"type":"text","text":"A"},
19463            {"type":"hardBreak"},
19464            {"type":"hardBreak"},
19465            {"type":"text","text":"B"}
19466          ]
19467        }]}"#;
19468        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19469        let md = adf_to_markdown(&doc).unwrap();
19470        let rt = markdown_to_adf(&md).unwrap();
19471
19472        assert_eq!(rt.content.len(), 1, "Should remain a single heading");
19473        assert_eq!(rt.content[0].node_type, "heading");
19474        let inlines = rt.content[0].content.as_ref().unwrap();
19475        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19476        assert_eq!(types, vec!["text", "hardBreak", "hardBreak", "text"]);
19477    }
19478
19479    #[test]
19480    fn issue_433_heading_with_strong_and_hardbreak_roundtrips() {
19481        // Heading with strong-marked text + hardBreak + plain text.
19482        let adf_json = r#"{"version":1,"type":"doc","content":[{
19483          "type":"heading",
19484          "attrs":{"level":1},
19485          "content":[
19486            {"type":"text","text":"Bold title","marks":[{"type":"strong"}]},
19487            {"type":"hardBreak"},
19488            {"type":"text","text":"plain continuation"}
19489          ]
19490        }]}"#;
19491        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19492        let md = adf_to_markdown(&doc).unwrap();
19493        let rt = markdown_to_adf(&md).unwrap();
19494
19495        assert_eq!(rt.content.len(), 1);
19496        assert_eq!(rt.content[0].node_type, "heading");
19497        let inlines = rt.content[0].content.as_ref().unwrap();
19498        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19499        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19500        assert_eq!(inlines[0].text.as_deref(), Some("Bold title"));
19501        assert_eq!(inlines[2].text.as_deref(), Some("plain continuation"));
19502    }
19503
19504    #[test]
19505    fn issue_433_heading_with_link_and_hardbreak_roundtrips() {
19506        // Real-world pattern: heading with link + hardBreak + text.
19507        let adf_json = r#"{"version":1,"type":"doc","content":[{
19508          "type":"heading",
19509          "attrs":{"level":1},
19510          "content":[
19511            {"type":"text","text":"Click here","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
19512            {"type":"hardBreak"},
19513            {"type":"text","text":"Subtitle text"}
19514          ]
19515        }]}"#;
19516        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19517        let md = adf_to_markdown(&doc).unwrap();
19518        let rt = markdown_to_adf(&md).unwrap();
19519
19520        assert_eq!(rt.content.len(), 1);
19521        assert_eq!(rt.content[0].node_type, "heading");
19522        let inlines = rt.content[0].content.as_ref().unwrap();
19523        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19524        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19525        assert_eq!(inlines[2].text.as_deref(), Some("Subtitle text"));
19526    }
19527
19528    #[test]
19529    fn has_trailing_hard_break_backslash() {
19530        assert!(has_trailing_hard_break("text\\"));
19531        assert!(has_trailing_hard_break("**bold**\\"));
19532    }
19533
19534    #[test]
19535    fn has_trailing_hard_break_trailing_spaces() {
19536        assert!(has_trailing_hard_break("text  "));
19537        assert!(has_trailing_hard_break("word   "));
19538    }
19539
19540    #[test]
19541    fn has_trailing_hard_break_false() {
19542        assert!(!has_trailing_hard_break("plain text"));
19543        assert!(!has_trailing_hard_break("text "));
19544        assert!(!has_trailing_hard_break(""));
19545    }
19546
19547    #[test]
19548    fn collect_hardbreak_continuations_collects_indented() {
19549        // A line ending with `\` followed by 2-space-indented continuation.
19550        // Only one line is collected because the result no longer ends with `\`.
19551        let input = "first\\\n  second\n  third\n";
19552        let mut parser = MarkdownParser::new(input);
19553        parser.advance(); // skip first line
19554        let mut text = "first\\".to_string();
19555        parser.collect_hardbreak_continuations(&mut text);
19556        assert_eq!(text, "first\\\nsecond");
19557    }
19558
19559    #[test]
19560    fn collect_hardbreak_continuations_stops_at_non_indented() {
19561        let input = "first\\\nnot indented\n";
19562        let mut parser = MarkdownParser::new(input);
19563        parser.advance();
19564        let mut text = "first\\".to_string();
19565        parser.collect_hardbreak_continuations(&mut text);
19566        // Should NOT collect the non-indented line
19567        assert_eq!(text, "first\\");
19568    }
19569
19570    #[test]
19571    fn collect_hardbreak_continuations_no_trailing_break() {
19572        // If the text doesn't end with a hardBreak marker, nothing is collected.
19573        let input = "plain\n  indented\n";
19574        let mut parser = MarkdownParser::new(input);
19575        parser.advance();
19576        let mut text = "plain".to_string();
19577        parser.collect_hardbreak_continuations(&mut text);
19578        assert_eq!(text, "plain");
19579    }
19580
19581    #[test]
19582    fn collect_hardbreak_continuations_chained() {
19583        // Multiple continuation lines chained via repeated hardBreaks.
19584        let input = "a\\\n  b\\\n  c\\\n  d\n";
19585        let mut parser = MarkdownParser::new(input);
19586        parser.advance();
19587        let mut text = "a\\".to_string();
19588        parser.collect_hardbreak_continuations(&mut text);
19589        assert_eq!(text, "a\\\nb\\\nc\\\nd");
19590    }
19591
19592    #[test]
19593    fn collect_hardbreak_continuations_stops_before_image_line() {
19594        // An indented continuation that starts with `![` (mediaSingle syntax)
19595        // must NOT be swallowed as a paragraph continuation (issue #490).
19596        let input = "text\\\n  ![](url){type=file id=x}\n";
19597        let mut parser = MarkdownParser::new(input);
19598        parser.advance(); // skip first line
19599        let mut text = "text\\".to_string();
19600        parser.collect_hardbreak_continuations(&mut text);
19601        // The image line should NOT have been consumed.
19602        assert_eq!(text, "text\\");
19603        // Parser should still be on the image line (not past it).
19604        assert!(!parser.at_end());
19605        assert!(parser.current_line().contains("![](url)"));
19606    }
19607
19608    #[test]
19609    fn is_block_level_continuation_marker_positive_cases() {
19610        // Each marker that forces `collect_hardbreak_continuations` to stop.
19611        assert!(is_block_level_continuation_marker("![](url)"));
19612        assert!(is_block_level_continuation_marker("```ruby"));
19613        assert!(is_block_level_continuation_marker(":::panel{type=info}"));
19614    }
19615
19616    #[test]
19617    fn is_block_level_continuation_marker_negative_cases() {
19618        // Plain continuation text must NOT look like a block-level marker.
19619        assert!(!is_block_level_continuation_marker("plain text"));
19620        assert!(!is_block_level_continuation_marker("- nested item"));
19621        assert!(!is_block_level_continuation_marker("continuation\\"));
19622        assert!(!is_block_level_continuation_marker(""));
19623        // Double-colon `::` is not a container directive.
19624        assert!(!is_block_level_continuation_marker("::partial"));
19625        // Single backticks are inline code, not a fence.
19626        assert!(!is_block_level_continuation_marker("`inline`"));
19627    }
19628
19629    #[test]
19630    fn collect_hardbreak_continuations_stops_before_code_fence() {
19631        // Issue #552: An indented continuation that opens a fenced code block
19632        // must NOT be swallowed as a paragraph continuation — it has to stay
19633        // available for `try_code_block` on the next parse iteration.
19634        let input = "text\\\n  ```ruby\n  Foo::Bar::Baz\n  ```\n";
19635        let mut parser = MarkdownParser::new(input);
19636        parser.advance();
19637        let mut text = "text\\".to_string();
19638        parser.collect_hardbreak_continuations(&mut text);
19639        assert_eq!(text, "text\\");
19640        assert!(!parser.at_end());
19641        assert!(parser.current_line().starts_with("  ```"));
19642    }
19643
19644    #[test]
19645    fn collect_hardbreak_continuations_stops_before_container_directive() {
19646        // Issue #552: An indented continuation that opens a `:::` container
19647        // directive (panel, expand, etc.) must also stay available for the
19648        // directive parser.
19649        let input = "text\\\n  :::panel{type=info}\n  body\n  :::\n";
19650        let mut parser = MarkdownParser::new(input);
19651        parser.advance();
19652        let mut text = "text\\".to_string();
19653        parser.collect_hardbreak_continuations(&mut text);
19654        assert_eq!(text, "text\\");
19655        assert!(!parser.at_end());
19656        assert!(parser.current_line().contains(":::panel"));
19657    }
19658
19659    #[test]
19660    fn collect_hardbreak_continuations_stops_before_indented_code_fence() {
19661        // Variant: extra leading whitespace on the code-fence line (so the
19662        // stripped tail is `  ```` rather than a bare ` ``` `) must still be
19663        // recognised by the `trim_start().starts_with("```")` check.
19664        let input = "text\\\n     ```text\n     :fire:\n     ```\n";
19665        let mut parser = MarkdownParser::new(input);
19666        parser.advance();
19667        let mut text = "text\\".to_string();
19668        parser.collect_hardbreak_continuations(&mut text);
19669        assert_eq!(text, "text\\");
19670        assert!(!parser.at_end());
19671        assert!(parser.current_line().contains("```text"));
19672    }
19673
19674    #[test]
19675    fn ordered_list_with_sub_content_after_hardbreak() {
19676        // Exercises the sub-content collection loop in parse_ordered_list
19677        // (lines 339-347) with a hardBreak item that also has a nested list.
19678        let adf_json = r#"{"version":1,"type":"doc","content":[
19679          {"type":"orderedList","attrs":{"order":1},"content":[
19680            {"type":"listItem","content":[
19681              {"type":"paragraph","content":[
19682                {"type":"text","text":"parent"},
19683                {"type":"hardBreak"},
19684                {"type":"text","text":"continued"}
19685              ]},
19686              {"type":"bulletList","content":[
19687                {"type":"listItem","content":[
19688                  {"type":"paragraph","content":[
19689                    {"type":"text","text":"child"}
19690                  ]}
19691                ]}
19692              ]}
19693            ]}
19694          ]}
19695        ]}"#;
19696        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19697        let md = adf_to_markdown(&doc).unwrap();
19698        let rt = markdown_to_adf(&md).unwrap();
19699
19700        assert_eq!(rt.content.len(), 1);
19701        assert_eq!(rt.content[0].node_type, "orderedList");
19702        let item_content = rt.content[0].content.as_ref().unwrap()[0]
19703            .content
19704            .as_ref()
19705            .unwrap();
19706        // Paragraph with hardBreak
19707        let p = item_content[0].content.as_ref().unwrap();
19708        let types: Vec<&str> = p.iter().map(|n| n.node_type.as_str()).collect();
19709        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19710        assert_eq!(p[0].text.as_deref(), Some("parent"));
19711        assert_eq!(p[2].text.as_deref(), Some("continued"));
19712        // Nested bullet list
19713        assert_eq!(item_content[1].node_type, "bulletList");
19714    }
19715
19716    #[test]
19717    fn render_list_item_content_no_content() {
19718        // A listItem with content: None should produce just a newline.
19719        let item = AdfNode {
19720            node_type: "listItem".to_string(),
19721            attrs: None,
19722            content: None,
19723            text: None,
19724            marks: None,
19725            local_id: None,
19726            parameters: None,
19727        };
19728        let mut output = String::new();
19729        let opts = RenderOptions::default();
19730        render_list_item_content(&item, &mut output, &opts);
19731        assert_eq!(output, "\n");
19732    }
19733
19734    #[test]
19735    fn render_list_item_content_empty_content() {
19736        // A listItem with content: Some(vec![]) should produce just a newline.
19737        let item = AdfNode::list_item(vec![]);
19738        let mut output = String::new();
19739        let opts = RenderOptions::default();
19740        render_list_item_content(&item, &mut output, &opts);
19741        assert_eq!(output, "\n");
19742    }
19743
19744    #[test]
19745    fn plus_bullet_paragraph_roundtrips() {
19746        // Paragraph starting with "+ " must round-trip without becoming
19747        // a bullet list.
19748        let adf_json = r#"{"version":1,"type":"doc","content":[
19749          {"type":"paragraph","content":[
19750            {"type":"text","text":"+ plus"}
19751          ]}
19752        ]}"#;
19753        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19754        let md = adf_to_markdown(&doc).unwrap();
19755        let rt = markdown_to_adf(&md).unwrap();
19756        assert_eq!(rt.content[0].node_type, "paragraph");
19757        assert_eq!(
19758            rt.content[0].content.as_ref().unwrap()[0]
19759                .text
19760                .as_deref()
19761                .unwrap(),
19762            "+ plus"
19763        );
19764    }
19765
19766    // ---- Issue #430 tests: mediaSingle inside listItem ----
19767
19768    #[test]
19769    fn issue_430_file_media_in_bullet_list_roundtrip() {
19770        // Issue #430: mediaSingle (type:file) as direct child of listItem
19771        // in a bulletList must survive round-trip.
19772        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19773          {"type":"listItem","content":[{
19774            "type":"mediaSingle",
19775            "attrs":{"layout":"center","width":1009,"widthType":"pixel"},
19776            "content":[{
19777              "type":"media",
19778              "attrs":{"collection":"contentId-123","height":576,"id":"00066e8e-554e-4d7e-af59-a0ef2888bdb6","type":"file","width":1009}
19779            }]
19780          }]}
19781        ]}]}"#;
19782        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19783        let md = adf_to_markdown(&doc).unwrap();
19784        let rt = markdown_to_adf(&md).unwrap();
19785
19786        let list = &rt.content[0];
19787        assert_eq!(list.node_type, "bulletList");
19788        let item = &list.content.as_ref().unwrap()[0];
19789        assert_eq!(item.node_type, "listItem");
19790        let ms = &item.content.as_ref().unwrap()[0];
19791        assert_eq!(ms.node_type, "mediaSingle");
19792        let ms_attrs = ms.attrs.as_ref().unwrap();
19793        assert_eq!(ms_attrs["layout"], "center");
19794        assert_eq!(ms_attrs["width"], 1009);
19795        assert_eq!(ms_attrs["widthType"], "pixel");
19796        let media = &ms.content.as_ref().unwrap()[0];
19797        assert_eq!(media.node_type, "media");
19798        let m_attrs = media.attrs.as_ref().unwrap();
19799        assert_eq!(m_attrs["type"], "file");
19800        assert_eq!(m_attrs["id"], "00066e8e-554e-4d7e-af59-a0ef2888bdb6");
19801        assert_eq!(m_attrs["collection"], "contentId-123");
19802        assert_eq!(m_attrs["height"], 576);
19803        assert_eq!(m_attrs["width"], 1009);
19804    }
19805
19806    #[test]
19807    fn issue_430_file_media_in_ordered_list_roundtrip() {
19808        // Same as above but inside an orderedList.
19809        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
19810          {"type":"listItem","content":[{
19811            "type":"mediaSingle",
19812            "attrs":{"layout":"center"},
19813            "content":[{
19814              "type":"media",
19815              "attrs":{"type":"file","id":"abc-123","collection":"contentId-456","height":100,"width":200}
19816            }]
19817          }]}
19818        ]}]}"#;
19819        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19820        let md = adf_to_markdown(&doc).unwrap();
19821        let rt = markdown_to_adf(&md).unwrap();
19822
19823        let list = &rt.content[0];
19824        assert_eq!(list.node_type, "orderedList");
19825        let item = &list.content.as_ref().unwrap()[0];
19826        assert_eq!(item.node_type, "listItem");
19827        let ms = &item.content.as_ref().unwrap()[0];
19828        assert_eq!(ms.node_type, "mediaSingle");
19829        let media = &ms.content.as_ref().unwrap()[0];
19830        assert_eq!(media.node_type, "media");
19831        let m_attrs = media.attrs.as_ref().unwrap();
19832        assert_eq!(m_attrs["type"], "file");
19833        assert_eq!(m_attrs["id"], "abc-123");
19834        assert_eq!(m_attrs["collection"], "contentId-456");
19835    }
19836
19837    #[test]
19838    fn issue_430_external_media_in_bullet_list_roundtrip() {
19839        // External image (type:external) inside a bullet list item.
19840        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19841          {"type":"listItem","content":[{
19842            "type":"mediaSingle",
19843            "attrs":{"layout":"center"},
19844            "content":[{
19845              "type":"media",
19846              "attrs":{"type":"external","url":"https://example.com/img.png","alt":"Photo"}
19847            }]
19848          }]}
19849        ]}]}"#;
19850        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19851        let md = adf_to_markdown(&doc).unwrap();
19852        let rt = markdown_to_adf(&md).unwrap();
19853
19854        let list = &rt.content[0];
19855        assert_eq!(list.node_type, "bulletList");
19856        let item = &list.content.as_ref().unwrap()[0];
19857        let ms = &item.content.as_ref().unwrap()[0];
19858        assert_eq!(ms.node_type, "mediaSingle");
19859        let media = &ms.content.as_ref().unwrap()[0];
19860        assert_eq!(media.node_type, "media");
19861        let m_attrs = media.attrs.as_ref().unwrap();
19862        assert_eq!(m_attrs["type"], "external");
19863        assert_eq!(m_attrs["url"], "https://example.com/img.png");
19864    }
19865
19866    #[test]
19867    fn issue_430_media_with_paragraph_siblings_in_list_item() {
19868        // listItem containing a paragraph followed by a mediaSingle.
19869        // Both children must survive round-trip.
19870        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19871          {"type":"listItem","content":[
19872            {"type":"paragraph","content":[{"type":"text","text":"Caption:"}]},
19873            {"type":"mediaSingle","attrs":{"layout":"center"},
19874             "content":[{"type":"media","attrs":{"type":"file","id":"img-001","collection":"col-1","height":50,"width":100}}]}
19875          ]}
19876        ]}]}"#;
19877        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19878        let md = adf_to_markdown(&doc).unwrap();
19879        let rt = markdown_to_adf(&md).unwrap();
19880
19881        let item = &rt.content[0].content.as_ref().unwrap()[0];
19882        let children = item.content.as_ref().unwrap();
19883        assert_eq!(children.len(), 2, "expected 2 children in listItem");
19884        assert_eq!(children[0].node_type, "paragraph");
19885        assert_eq!(children[1].node_type, "mediaSingle");
19886        let media = &children[1].content.as_ref().unwrap()[0];
19887        assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-001");
19888    }
19889
19890    #[test]
19891    fn issue_430_multiple_media_in_list_items() {
19892        // Multiple list items each containing mediaSingle.
19893        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19894          {"type":"listItem","content":[{
19895            "type":"mediaSingle","attrs":{"layout":"center"},
19896            "content":[{"type":"media","attrs":{"type":"file","id":"img-a","collection":"c1","height":10,"width":20}}]
19897          }]},
19898          {"type":"listItem","content":[{
19899            "type":"mediaSingle","attrs":{"layout":"center"},
19900            "content":[{"type":"media","attrs":{"type":"file","id":"img-b","collection":"c2","height":30,"width":40}}]
19901          }]}
19902        ]}]}"#;
19903        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19904        let md = adf_to_markdown(&doc).unwrap();
19905        let rt = markdown_to_adf(&md).unwrap();
19906
19907        let items = rt.content[0].content.as_ref().unwrap();
19908        assert_eq!(items.len(), 2);
19909        for (i, expected_id) in [("img-a", "c1"), ("img-b", "c2")].iter().enumerate() {
19910            let ms = &items[i].content.as_ref().unwrap()[0];
19911            assert_eq!(ms.node_type, "mediaSingle");
19912            let m_attrs = ms.content.as_ref().unwrap()[0].attrs.as_ref().unwrap();
19913            assert_eq!(m_attrs["id"], expected_id.0);
19914            assert_eq!(m_attrs["collection"], expected_id.1);
19915        }
19916    }
19917
19918    #[test]
19919    fn issue_430_jfm_to_adf_media_in_bullet_item() {
19920        // Parse JFM directly: image syntax on the first line of a bullet item
19921        // must produce mediaSingle, not a paragraph with corrupted text.
19922        let md = "- ![](){type=file id=test-id collection=col-1 height=100 width=200}\n";
19923        let doc = markdown_to_adf(md).unwrap();
19924
19925        let list = &doc.content[0];
19926        assert_eq!(list.node_type, "bulletList");
19927        let item = &list.content.as_ref().unwrap()[0];
19928        let ms = &item.content.as_ref().unwrap()[0];
19929        assert_eq!(
19930            ms.node_type, "mediaSingle",
19931            "expected mediaSingle, got {}",
19932            ms.node_type
19933        );
19934        let media = &ms.content.as_ref().unwrap()[0];
19935        assert_eq!(media.node_type, "media");
19936        let m_attrs = media.attrs.as_ref().unwrap();
19937        assert_eq!(m_attrs["type"], "file");
19938        assert_eq!(m_attrs["id"], "test-id");
19939    }
19940
19941    #[test]
19942    fn issue_430_jfm_to_adf_media_in_ordered_item() {
19943        // Parse JFM directly: image syntax on the first line of an ordered list item.
19944        let md = "1. ![alt text](https://example.com/photo.jpg)\n";
19945        let doc = markdown_to_adf(md).unwrap();
19946
19947        let list = &doc.content[0];
19948        assert_eq!(list.node_type, "orderedList");
19949        let item = &list.content.as_ref().unwrap()[0];
19950        let ms = &item.content.as_ref().unwrap()[0];
19951        assert_eq!(
19952            ms.node_type, "mediaSingle",
19953            "expected mediaSingle, got {}",
19954            ms.node_type
19955        );
19956    }
19957
19958    #[test]
19959    fn issue_430_media_then_paragraph_in_bullet_list_roundtrip() {
19960        // listItem with mediaSingle as first child followed by a paragraph.
19961        // Exercises the sub_lines non-empty path when first_node is mediaSingle.
19962        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19963          {"type":"listItem","content":[
19964            {"type":"mediaSingle","attrs":{"layout":"center"},
19965             "content":[{"type":"media","attrs":{"type":"file","id":"img-first","collection":"col-1","height":50,"width":100}}]},
19966            {"type":"paragraph","content":[{"type":"text","text":"Caption below"}]}
19967          ]}
19968        ]}]}"#;
19969        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19970        let md = adf_to_markdown(&doc).unwrap();
19971        let rt = markdown_to_adf(&md).unwrap();
19972
19973        let item = &rt.content[0].content.as_ref().unwrap()[0];
19974        let children = item.content.as_ref().unwrap();
19975        assert_eq!(children.len(), 2, "expected 2 children in listItem");
19976        assert_eq!(children[0].node_type, "mediaSingle");
19977        let media = &children[0].content.as_ref().unwrap()[0];
19978        assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-first");
19979        assert_eq!(children[1].node_type, "paragraph");
19980    }
19981
19982    #[test]
19983    fn issue_430_media_then_paragraph_in_ordered_list_roundtrip() {
19984        // Same as above but for ordered lists.
19985        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
19986          {"type":"listItem","content":[
19987            {"type":"mediaSingle","attrs":{"layout":"center"},
19988             "content":[{"type":"media","attrs":{"type":"file","id":"img-ord","collection":"col-2","height":60,"width":120}}]},
19989            {"type":"paragraph","content":[{"type":"text","text":"Description"}]}
19990          ]}
19991        ]}]}"#;
19992        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19993        let md = adf_to_markdown(&doc).unwrap();
19994        let rt = markdown_to_adf(&md).unwrap();
19995
19996        let item = &rt.content[0].content.as_ref().unwrap()[0];
19997        let children = item.content.as_ref().unwrap();
19998        assert_eq!(children.len(), 2, "expected 2 children in listItem");
19999        assert_eq!(children[0].node_type, "mediaSingle");
20000        assert_eq!(children[1].node_type, "paragraph");
20001    }
20002
20003    #[test]
20004    fn issue_430_external_media_with_width_type_roundtrip() {
20005        // External image with widthType attr must survive round-trip.
20006        let adf_json = r#"{"version":1,"type":"doc","content":[{
20007          "type":"mediaSingle",
20008          "attrs":{"layout":"wide","width":800,"widthType":"pixel"},
20009          "content":[{
20010            "type":"media",
20011            "attrs":{"type":"external","url":"https://example.com/photo.png","alt":"wide photo"}
20012          }]
20013        }]}"#;
20014        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20015        let md = adf_to_markdown(&doc).unwrap();
20016        assert!(
20017            md.contains("widthType=pixel"),
20018            "expected widthType=pixel in markdown, got: {md}"
20019        );
20020        let rt = markdown_to_adf(&md).unwrap();
20021        let ms = &rt.content[0];
20022        assert_eq!(ms.node_type, "mediaSingle");
20023        let ms_attrs = ms.attrs.as_ref().unwrap();
20024        assert_eq!(ms_attrs["widthType"], "pixel");
20025        assert_eq!(ms_attrs["width"], 800);
20026        assert_eq!(ms_attrs["layout"], "wide");
20027    }
20028
20029    // ── Issue #490: mediaSingle after hardBreak in listItem ─────
20030
20031    #[test]
20032    fn issue_490_paragraph_with_hardbreak_then_media_single_roundtrip() {
20033        // Reproducer from issue #490: paragraph with trailing hardBreak
20034        // followed by mediaSingle inside a listItem.
20035        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20036          {"type":"listItem","content":[
20037            {"type":"paragraph","content":[
20038              {"type":"text","text":"Item with image:"},
20039              {"type":"hardBreak"}
20040            ]},
20041            {"type":"mediaSingle","attrs":{"layout":"center","width":400,"widthType":"pixel"},
20042             "content":[{"type":"media","attrs":{
20043               "id":"aabbccdd-1234-5678-abcd-aabbccdd1234",
20044               "type":"file",
20045               "collection":"contentId-123456",
20046               "width":800,
20047               "height":600
20048             }}]}
20049          ]}
20050        ]}]}"#;
20051        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20052        let md = adf_to_markdown(&doc).unwrap();
20053        let rt = markdown_to_adf(&md).unwrap();
20054
20055        let item = &rt.content[0].content.as_ref().unwrap()[0];
20056        let children = item.content.as_ref().unwrap();
20057        assert_eq!(children.len(), 2, "expected 2 children in listItem");
20058        assert_eq!(children[0].node_type, "paragraph");
20059        assert_eq!(
20060            children[1].node_type, "mediaSingle",
20061            "expected mediaSingle, got {:?}",
20062            children[1].node_type
20063        );
20064        let media = &children[1].content.as_ref().unwrap()[0];
20065        let m_attrs = media.attrs.as_ref().unwrap();
20066        assert_eq!(m_attrs["id"], "aabbccdd-1234-5678-abcd-aabbccdd1234");
20067        assert_eq!(m_attrs["collection"], "contentId-123456");
20068        assert_eq!(m_attrs["height"], 600);
20069        assert_eq!(m_attrs["width"], 800);
20070    }
20071
20072    #[test]
20073    fn issue_490_paragraph_with_hardbreak_then_media_single_ordered_list() {
20074        // Same scenario but in an ordered list.
20075        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
20076          {"type":"listItem","content":[
20077            {"type":"paragraph","content":[
20078              {"type":"text","text":"Step with screenshot:"},
20079              {"type":"hardBreak"}
20080            ]},
20081            {"type":"mediaSingle","attrs":{"layout":"center"},
20082             "content":[{"type":"media","attrs":{
20083               "id":"ord-media-id","type":"file","collection":"col-ord","width":640,"height":480
20084             }}]}
20085          ]}
20086        ]}]}"#;
20087        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20088        let md = adf_to_markdown(&doc).unwrap();
20089        let rt = markdown_to_adf(&md).unwrap();
20090
20091        let item = &rt.content[0].content.as_ref().unwrap()[0];
20092        let children = item.content.as_ref().unwrap();
20093        assert_eq!(children.len(), 2, "expected 2 children in listItem");
20094        assert_eq!(children[0].node_type, "paragraph");
20095        assert_eq!(children[1].node_type, "mediaSingle");
20096        let media = &children[1].content.as_ref().unwrap()[0];
20097        assert_eq!(media.attrs.as_ref().unwrap()["id"], "ord-media-id");
20098    }
20099
20100    #[test]
20101    fn issue_490_hardbreak_continuation_does_not_swallow_media_line() {
20102        // Directly tests that collect_hardbreak_continuations stops before
20103        // an indented mediaSingle line.
20104        let md = "- Item with image:\\\n  ![](){type=file id=test-490 collection=col height=100 width=200}\n";
20105        let doc = markdown_to_adf(md).unwrap();
20106
20107        let item = &doc.content[0].content.as_ref().unwrap()[0];
20108        let children = item.content.as_ref().unwrap();
20109        assert_eq!(children.len(), 2, "expected 2 children in listItem");
20110        assert_eq!(children[0].node_type, "paragraph");
20111        assert_eq!(
20112            children[1].node_type, "mediaSingle",
20113            "expected mediaSingle as second child, got {:?}",
20114            children[1].node_type
20115        );
20116        let media = &children[1].content.as_ref().unwrap()[0];
20117        assert_eq!(media.attrs.as_ref().unwrap()["id"], "test-490");
20118    }
20119
20120    #[test]
20121    fn issue_490_hardbreak_continuation_still_works_for_text() {
20122        // Ensure regular hardBreak continuations still work after the fix.
20123        let md = "- first line\\\n  second line\n";
20124        let doc = markdown_to_adf(md).unwrap();
20125
20126        let item = &doc.content[0].content.as_ref().unwrap()[0];
20127        let children = item.content.as_ref().unwrap();
20128        assert_eq!(
20129            children.len(),
20130            1,
20131            "expected 1 child (paragraph) in listItem"
20132        );
20133        assert_eq!(children[0].node_type, "paragraph");
20134        let inlines = children[0].content.as_ref().unwrap();
20135        // Should contain: text("first line"), hardBreak, text("second line")
20136        assert_eq!(inlines.len(), 3);
20137        assert_eq!(inlines[0].node_type, "text");
20138        assert_eq!(inlines[1].node_type, "hardBreak");
20139        assert_eq!(inlines[2].node_type, "text");
20140    }
20141
20142    #[test]
20143    fn issue_490_external_media_after_hardbreak_roundtrip() {
20144        // External image (URL-based) after a paragraph with hardBreak.
20145        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20146          {"type":"listItem","content":[
20147            {"type":"paragraph","content":[
20148              {"type":"text","text":"See image:"},
20149              {"type":"hardBreak"}
20150            ]},
20151            {"type":"mediaSingle","attrs":{"layout":"center"},
20152             "content":[{"type":"media","attrs":{
20153               "type":"external","url":"https://example.com/photo.png","alt":"photo"
20154             }}]}
20155          ]}
20156        ]}]}"#;
20157        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20158        let md = adf_to_markdown(&doc).unwrap();
20159        let rt = markdown_to_adf(&md).unwrap();
20160
20161        let item = &rt.content[0].content.as_ref().unwrap()[0];
20162        let children = item.content.as_ref().unwrap();
20163        assert_eq!(children.len(), 2);
20164        assert_eq!(children[0].node_type, "paragraph");
20165        assert_eq!(children[1].node_type, "mediaSingle");
20166        let media = &children[1].content.as_ref().unwrap()[0];
20167        let m_attrs = media.attrs.as_ref().unwrap();
20168        assert_eq!(m_attrs["url"], "https://example.com/photo.png");
20169    }
20170
20171    #[test]
20172    fn issue_490_multiple_hardbreaks_then_media_single() {
20173        // Paragraph with multiple hardBreaks, then mediaSingle.
20174        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20175          {"type":"listItem","content":[
20176            {"type":"paragraph","content":[
20177              {"type":"text","text":"line one"},
20178              {"type":"hardBreak"},
20179              {"type":"text","text":"line two"},
20180              {"type":"hardBreak"}
20181            ]},
20182            {"type":"mediaSingle","attrs":{"layout":"center"},
20183             "content":[{"type":"media","attrs":{
20184               "type":"file","id":"multi-hb","collection":"col-m","width":320,"height":240
20185             }}]}
20186          ]}
20187        ]}]}"#;
20188        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20189        let md = adf_to_markdown(&doc).unwrap();
20190        let rt = markdown_to_adf(&md).unwrap();
20191
20192        let item = &rt.content[0].content.as_ref().unwrap()[0];
20193        let children = item.content.as_ref().unwrap();
20194        assert_eq!(children.len(), 2, "expected paragraph + mediaSingle");
20195        assert_eq!(children[0].node_type, "paragraph");
20196        assert_eq!(children[1].node_type, "mediaSingle");
20197        let media = &children[1].content.as_ref().unwrap()[0];
20198        assert_eq!(media.attrs.as_ref().unwrap()["id"], "multi-hb");
20199    }
20200
20201    // ── Issue #525: listItem localId dropped when content includes mediaSingle ──
20202
20203    #[test]
20204    fn issue_525_listitem_localid_with_mediasingle_roundtrip() {
20205        // Exact reproducer from issue #525: listItem with UUID localId whose
20206        // content includes mediaSingle + paragraph + nested bulletList.
20207        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"}]}]}]}]}]}]}"#;
20208        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20209        let md = adf_to_markdown(&doc).unwrap();
20210        let rt = markdown_to_adf(&md).unwrap();
20211
20212        let list = &rt.content[0];
20213        assert_eq!(list.node_type, "bulletList");
20214        let item = &list.content.as_ref().unwrap()[0];
20215        // The localId must be preserved on the listItem.
20216        let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20217        assert_eq!(
20218            item_attrs["localId"], "aabbccdd-1234-5678-abcd-000000000001",
20219            "listItem localId must survive round-trip"
20220        );
20221        let children = item.content.as_ref().unwrap();
20222        assert_eq!(
20223            children.len(),
20224            3,
20225            "expected mediaSingle + paragraph + bulletList"
20226        );
20227        assert_eq!(children[0].node_type, "mediaSingle");
20228        assert_eq!(children[1].node_type, "paragraph");
20229        assert_eq!(children[2].node_type, "bulletList");
20230    }
20231
20232    #[test]
20233    fn issue_525_listitem_localid_with_mediasingle_only() {
20234        // Minimal case: listItem with localId whose sole child is mediaSingle.
20235        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20236          {"type":"listItem","attrs":{"localId":"li-media-only"},"content":[
20237            {"type":"mediaSingle","attrs":{"layout":"center"},
20238             "content":[{"type":"media","attrs":{"type":"file","id":"m-001","collection":"c1","height":50,"width":100}}]}
20239          ]}
20240        ]}]}"#;
20241        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20242        let md = adf_to_markdown(&doc).unwrap();
20243        let rt = markdown_to_adf(&md).unwrap();
20244
20245        let item = &rt.content[0].content.as_ref().unwrap()[0];
20246        let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20247        assert_eq!(
20248            item_attrs["localId"], "li-media-only",
20249            "listItem localId must survive when sole child is mediaSingle"
20250        );
20251        assert_eq!(item.content.as_ref().unwrap()[0].node_type, "mediaSingle");
20252    }
20253
20254    #[test]
20255    fn issue_525_listitem_localid_with_external_media() {
20256        // External image (URL-based) as first child with listItem localId.
20257        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20258          {"type":"listItem","attrs":{"localId":"li-ext-media"},"content":[
20259            {"type":"mediaSingle","attrs":{"layout":"center"},
20260             "content":[{"type":"media","attrs":{"type":"external","url":"https://example.com/img.png","alt":"photo"}}]}
20261          ]}
20262        ]}]}"#;
20263        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20264        let md = adf_to_markdown(&doc).unwrap();
20265        let rt = markdown_to_adf(&md).unwrap();
20266
20267        let item = &rt.content[0].content.as_ref().unwrap()[0];
20268        let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20269        assert_eq!(
20270            item_attrs["localId"], "li-ext-media",
20271            "listItem localId must survive with external mediaSingle"
20272        );
20273    }
20274
20275    #[test]
20276    fn issue_525_listitem_localid_with_mediasingle_in_ordered_list() {
20277        // Same bug in an ordered list.
20278        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
20279          {"type":"listItem","attrs":{"localId":"li-ord-media"},"content":[
20280            {"type":"mediaSingle","attrs":{"layout":"center","width":200,"widthType":"pixel"},
20281             "content":[{"type":"media","attrs":{"type":"file","id":"ord-m-001","collection":"col-ord","height":80,"width":160}}]},
20282            {"type":"paragraph","content":[{"type":"text","text":"ordered item text"}]}
20283          ]}
20284        ]}]}"#;
20285        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20286        let md = adf_to_markdown(&doc).unwrap();
20287        let rt = markdown_to_adf(&md).unwrap();
20288
20289        let item = &rt.content[0].content.as_ref().unwrap()[0];
20290        let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20291        assert_eq!(
20292            item_attrs["localId"], "li-ord-media",
20293            "listItem localId must survive in ordered list with mediaSingle"
20294        );
20295        let children = item.content.as_ref().unwrap();
20296        assert_eq!(children[0].node_type, "mediaSingle");
20297        assert_eq!(children[1].node_type, "paragraph");
20298    }
20299
20300    #[test]
20301    fn issue_525_jfm_localid_on_mediasingle_line_parses_correctly() {
20302        // Verify JFM→ADF: trailing {localId=...} on a mediaSingle line
20303        // is assigned to the listItem, not the media node.
20304        let md = "- ![](){type=file id=test-525 collection=col height=100 width=200 mediaWidth=100 widthType=pixel} {localId=li-jfm-525}\n";
20305        let doc = markdown_to_adf(md).unwrap();
20306
20307        let item = &doc.content[0].content.as_ref().unwrap()[0];
20308        let item_attrs = item
20309            .attrs
20310            .as_ref()
20311            .expect("listItem attrs must be present from JFM");
20312        assert_eq!(item_attrs["localId"], "li-jfm-525");
20313        assert_eq!(item.content.as_ref().unwrap()[0].node_type, "mediaSingle");
20314    }
20315
20316    #[test]
20317    fn issue_525_encoding_emits_localid_on_mediasingle_line() {
20318        // Verify the ADF→JFM encoding: localId appears on the same line
20319        // as the mediaSingle image syntax.
20320        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20321          {"type":"listItem","attrs":{"localId":"li-emit-check"},"content":[
20322            {"type":"mediaSingle","attrs":{"layout":"center"},
20323             "content":[{"type":"media","attrs":{"type":"file","id":"m-emit","collection":"c-emit","height":10,"width":20}}]}
20324          ]}
20325        ]}]}"#;
20326        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20327        let md = adf_to_markdown(&doc).unwrap();
20328        assert!(
20329            md.contains("{localId=li-emit-check}"),
20330            "expected localId in JFM output, got: {md}"
20331        );
20332        // The localId must be on the same line as the image
20333        for line in md.lines() {
20334            if line.contains("![") {
20335                assert!(
20336                    line.contains("localId=li-emit-check"),
20337                    "localId must be on the same line as the image: {line}"
20338                );
20339            }
20340        }
20341    }
20342
20343    // ── Placeholder node tests ────────────────────────────────────
20344
20345    #[test]
20346    fn adf_placeholder_to_markdown() {
20347        let doc = AdfDocument {
20348            version: 1,
20349            doc_type: "doc".to_string(),
20350            content: vec![AdfNode::paragraph(vec![AdfNode::placeholder(
20351                "Type something here",
20352            )])],
20353        };
20354        let md = adf_to_markdown(&doc).unwrap();
20355        assert!(
20356            md.contains(":placeholder[Type something here]"),
20357            "expected :placeholder directive, got: {md}"
20358        );
20359    }
20360
20361    #[test]
20362    fn markdown_placeholder_to_adf() {
20363        let doc = markdown_to_adf("Before :placeholder[Enter name] after").unwrap();
20364        let content = doc.content[0].content.as_ref().unwrap();
20365        assert_eq!(content[1].node_type, "placeholder");
20366        let attrs = content[1].attrs.as_ref().unwrap();
20367        assert_eq!(attrs["text"], "Enter name");
20368    }
20369
20370    #[test]
20371    fn placeholder_round_trip() {
20372        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"placeholder","attrs":{"text":"Type something here"}}]}]}"#;
20373        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20374        let md = adf_to_markdown(&doc).unwrap();
20375        let rt = markdown_to_adf(&md).unwrap();
20376        let content = rt.content[0].content.as_ref().unwrap();
20377        assert_eq!(content.len(), 1);
20378        assert_eq!(content[0].node_type, "placeholder");
20379        let attrs = content[0].attrs.as_ref().unwrap();
20380        assert_eq!(attrs["text"], "Type something here");
20381    }
20382
20383    #[test]
20384    fn placeholder_empty_text() {
20385        let doc = AdfDocument {
20386            version: 1,
20387            doc_type: "doc".to_string(),
20388            content: vec![AdfNode::paragraph(vec![AdfNode::placeholder("")])],
20389        };
20390        let md = adf_to_markdown(&doc).unwrap();
20391        assert!(
20392            md.contains(":placeholder[]"),
20393            "expected empty placeholder directive, got: {md}"
20394        );
20395        let rt = markdown_to_adf(&md).unwrap();
20396        let content = rt.content[0].content.as_ref().unwrap();
20397        assert_eq!(content[0].node_type, "placeholder");
20398        assert_eq!(content[0].attrs.as_ref().unwrap()["text"], "");
20399    }
20400
20401    #[test]
20402    fn placeholder_with_surrounding_text() {
20403        let md = "Click :placeholder[here] to continue\n";
20404        let doc = markdown_to_adf(md).unwrap();
20405        let content = doc.content[0].content.as_ref().unwrap();
20406        assert_eq!(content[0].text.as_deref(), Some("Click "));
20407        assert_eq!(content[1].node_type, "placeholder");
20408        assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "here");
20409        assert_eq!(content[2].text.as_deref(), Some(" to continue"));
20410    }
20411
20412    #[test]
20413    fn placeholder_missing_attrs() {
20414        // Placeholder node with no attrs should not panic
20415        let doc = AdfDocument {
20416            version: 1,
20417            doc_type: "doc".to_string(),
20418            content: vec![AdfNode::paragraph(vec![AdfNode {
20419                node_type: "placeholder".to_string(),
20420                attrs: None,
20421                content: None,
20422                text: None,
20423                marks: None,
20424                local_id: None,
20425                parameters: None,
20426            }])],
20427        };
20428        let md = adf_to_markdown(&doc).unwrap();
20429        // With no attrs, nothing is emitted for the placeholder
20430        assert!(!md.contains("placeholder"));
20431    }
20432
20433    // Issue #446: mention in table+list loses id and misplaces localId
20434    #[test]
20435    fn mention_in_table_bullet_list_preserves_id_and_local_id() {
20436        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":" "}]}]}]}]}]}]}]}"#;
20437        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20438        let md = adf_to_markdown(&doc).unwrap();
20439        let rt = markdown_to_adf(&md).unwrap();
20440
20441        // Navigate: doc → table → tableRow → tableCell → bulletList → listItem → paragraph
20442        let cell = &rt.content[0].content.as_ref().unwrap()[0]
20443            .content
20444            .as_ref()
20445            .unwrap()[0];
20446        let list = &cell.content.as_ref().unwrap()[0];
20447        let list_item = &list.content.as_ref().unwrap()[0];
20448
20449        // listItem must NOT have a localId attribute
20450        assert!(
20451            list_item
20452                .attrs
20453                .as_ref()
20454                .and_then(|a| a.get("localId"))
20455                .is_none(),
20456            "localId should stay on the mention, not the listItem"
20457        );
20458
20459        let para = &list_item.content.as_ref().unwrap()[0];
20460        let inlines = para.content.as_ref().unwrap();
20461
20462        // Should have: text("prefix text "), mention, text(" ")
20463        assert_eq!(inlines.len(), 3, "expected 3 inline nodes, got {inlines:?}");
20464
20465        assert_eq!(inlines[0].node_type, "text");
20466        assert_eq!(inlines[0].text.as_deref(), Some("prefix text "));
20467
20468        assert_eq!(inlines[1].node_type, "mention");
20469        let mention_attrs = inlines[1].attrs.as_ref().unwrap();
20470        assert_eq!(
20471            mention_attrs["id"], "aabbccdd11223344aabbccdd",
20472            "mention id must be preserved"
20473        );
20474        assert_eq!(
20475            mention_attrs["localId"], "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
20476            "mention localId must be preserved"
20477        );
20478        assert_eq!(mention_attrs["text"], "@Alice Example");
20479
20480        assert_eq!(inlines[2].node_type, "text");
20481        assert_eq!(inlines[2].text.as_deref(), Some(" "));
20482    }
20483
20484    #[test]
20485    fn mention_in_bullet_list_preserves_id_and_local_id() {
20486        // Same bug outside of a table context
20487        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":" "}]}]}]}]}"#;
20488        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20489        let md = adf_to_markdown(&doc).unwrap();
20490        let rt = markdown_to_adf(&md).unwrap();
20491
20492        let list_item = &rt.content[0].content.as_ref().unwrap()[0];
20493        assert!(
20494            list_item
20495                .attrs
20496                .as_ref()
20497                .and_then(|a| a.get("localId"))
20498                .is_none(),
20499            "localId should stay on the mention, not the listItem"
20500        );
20501
20502        let para = &list_item.content.as_ref().unwrap()[0];
20503        let inlines = para.content.as_ref().unwrap();
20504        assert_eq!(inlines[0].node_type, "mention");
20505        let mention_attrs = inlines[0].attrs.as_ref().unwrap();
20506        assert_eq!(mention_attrs["id"], "user123");
20507        assert_eq!(
20508            mention_attrs["localId"],
20509            "11111111-2222-3333-4444-555555555555"
20510        );
20511    }
20512
20513    #[test]
20514    fn mention_in_ordered_list_preserves_id_and_local_id() {
20515        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"}}]}]}]}]}"#;
20516        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20517        let md = adf_to_markdown(&doc).unwrap();
20518        let rt = markdown_to_adf(&md).unwrap();
20519
20520        let list_item = &rt.content[0].content.as_ref().unwrap()[0];
20521        assert!(
20522            list_item
20523                .attrs
20524                .as_ref()
20525                .and_then(|a| a.get("localId"))
20526                .is_none(),
20527            "localId should stay on the mention, not the listItem"
20528        );
20529
20530        let para = &list_item.content.as_ref().unwrap()[0];
20531        let inlines = para.content.as_ref().unwrap();
20532        assert_eq!(inlines[1].node_type, "mention");
20533        let mention_attrs = inlines[1].attrs.as_ref().unwrap();
20534        assert_eq!(mention_attrs["id"], "xyz");
20535        assert_eq!(mention_attrs["localId"], "aaaa-bbbb");
20536    }
20537
20538    #[test]
20539    fn list_item_own_local_id_with_mention_both_preserved() {
20540        // When a listItem has its own localId AND contains a mention with localId,
20541        // both should be preserved independently.
20542        let md = "- hello :mention[@Eve]{id=e1 localId=mention-lid} {localId=item-lid}\n";
20543        let doc = markdown_to_adf(md).unwrap();
20544        let list_item = &doc.content[0].content.as_ref().unwrap()[0];
20545
20546        // listItem should have its own localId
20547        let item_attrs = list_item.attrs.as_ref().unwrap();
20548        assert_eq!(item_attrs["localId"], "item-lid");
20549
20550        // mention should have its own localId
20551        let para = &list_item.content.as_ref().unwrap()[0];
20552        let inlines = para.content.as_ref().unwrap();
20553        let mention = inlines.iter().find(|n| n.node_type == "mention").unwrap();
20554        let mention_attrs = mention.attrs.as_ref().unwrap();
20555        assert_eq!(mention_attrs["id"], "e1");
20556        assert_eq!(mention_attrs["localId"], "mention-lid");
20557    }
20558
20559    #[test]
20560    fn extract_trailing_local_id_ignores_directive_attrs() {
20561        // Directly test the helper: a line ending with a directive's {…}
20562        // should NOT be treated as a trailing localId.
20563        let line = "text :mention[@X]{id=abc localId=uuid}";
20564        let (text, lid, plid) = extract_trailing_local_id(line);
20565        assert_eq!(text, line, "text should be unchanged");
20566        assert!(
20567            lid.is_none(),
20568            "should not extract localId from directive attrs"
20569        );
20570        assert!(plid.is_none());
20571    }
20572
20573    #[test]
20574    fn extract_trailing_local_id_matches_standalone_block() {
20575        // A standalone trailing {localId=…} separated by whitespace should still work.
20576        let line = "some text {localId=abc-123}";
20577        let (text, lid, plid) = extract_trailing_local_id(line);
20578        assert_eq!(text, "some text");
20579        assert_eq!(lid.as_deref(), Some("abc-123"));
20580        assert!(plid.is_none());
20581    }
20582
20583    // --- Issue #454: literal newline in text node inside listItem paragraph ---
20584
20585    #[test]
20586    fn newline_in_text_node_roundtrips_in_bullet_list() {
20587        // A text node containing a literal \n inside a bullet list item
20588        // must round-trip as a single text node with the embedded newline
20589        // preserved, not split into multiple paragraphs or hardBreak nodes.
20590        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"}]}]}]}]}"#;
20591        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20592        let md = adf_to_markdown(&doc).unwrap();
20593        let rt = markdown_to_adf(&md).unwrap();
20594
20595        // Should still be a single bulletList with one listItem
20596        assert_eq!(rt.content.len(), 1);
20597        let list = &rt.content[0];
20598        assert_eq!(list.node_type, "bulletList");
20599        let items = list.content.as_ref().unwrap();
20600        assert_eq!(items.len(), 1);
20601
20602        // The listItem should have exactly one paragraph child
20603        let item_content = items[0].content.as_ref().unwrap();
20604        assert_eq!(
20605            item_content.len(),
20606            1,
20607            "listItem should have exactly one paragraph"
20608        );
20609        assert_eq!(item_content[0].node_type, "paragraph");
20610
20611        // The embedded newline must survive as a single text node
20612        let inlines = item_content[0].content.as_ref().unwrap();
20613        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20614        assert_eq!(
20615            types,
20616            vec!["text", "hardBreak", "text"],
20617            "embedded newline should stay in a single text node, not produce extra hardBreaks"
20618        );
20619        assert_eq!(
20620            inlines[2].text.as_deref(),
20621            Some("first command\nsecond command")
20622        );
20623    }
20624
20625    #[test]
20626    fn newline_in_text_node_roundtrips_in_ordered_list() {
20627        // Same as above but in an ordered list.
20628        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"}]}]}]}]}"#;
20629        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20630        let md = adf_to_markdown(&doc).unwrap();
20631        let rt = markdown_to_adf(&md).unwrap();
20632
20633        let list = &rt.content[0];
20634        assert_eq!(list.node_type, "orderedList");
20635        let items = list.content.as_ref().unwrap();
20636        assert_eq!(items.len(), 1);
20637
20638        let item_content = items[0].content.as_ref().unwrap();
20639        assert_eq!(item_content.len(), 1);
20640        assert_eq!(item_content[0].node_type, "paragraph");
20641
20642        let inlines = item_content[0].content.as_ref().unwrap();
20643        assert_eq!(inlines.len(), 1);
20644        assert_eq!(inlines[0].node_type, "text");
20645        assert_eq!(inlines[0].text.as_deref(), Some("first\nsecond"));
20646    }
20647
20648    #[test]
20649    fn newline_in_text_node_roundtrips_in_paragraph() {
20650        // A text node with \n in a top-level paragraph should render as
20651        // escaped \n and round-trip back to a single text node.
20652        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello\nworld"}]}]}"#;
20653        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20654        let md = adf_to_markdown(&doc).unwrap();
20655        assert!(
20656            md.contains("hello\\nworld"),
20657            "newline in text node should render as escaped \\n: {md:?}"
20658        );
20659
20660        let rt = markdown_to_adf(&md).unwrap();
20661        let inlines = rt.content[0].content.as_ref().unwrap();
20662        assert_eq!(inlines.len(), 1);
20663        assert_eq!(inlines[0].text.as_deref(), Some("hello\nworld"));
20664    }
20665
20666    #[test]
20667    fn multiple_newlines_in_text_node_roundtrip() {
20668        // Multiple \n characters should each round-trip within the same text node.
20669        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a\nb\nc"}]}]}]}]}"#;
20670        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20671        let md = adf_to_markdown(&doc).unwrap();
20672        let rt = markdown_to_adf(&md).unwrap();
20673
20674        let item_content = rt.content[0].content.as_ref().unwrap()[0]
20675            .content
20676            .as_ref()
20677            .unwrap();
20678        assert_eq!(item_content.len(), 1);
20679
20680        let inlines = item_content[0].content.as_ref().unwrap();
20681        assert_eq!(inlines.len(), 1);
20682        assert_eq!(inlines[0].text.as_deref(), Some("a\nb\nc"));
20683    }
20684
20685    #[test]
20686    fn newline_in_marked_text_node_roundtrips() {
20687        // A bold text node with \n should round-trip preserving both
20688        // the marks and the embedded newline.
20689        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"bold\ntext","marks":[{"type":"strong"}]}]}]}"#;
20690        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20691        let md = adf_to_markdown(&doc).unwrap();
20692        assert!(
20693            md.contains("**bold\\ntext**"),
20694            "bold text with embedded newline should stay in one marked run: {md:?}"
20695        );
20696
20697        let rt = markdown_to_adf(&md).unwrap();
20698        let inlines = rt.content[0].content.as_ref().unwrap();
20699        assert_eq!(inlines.len(), 1);
20700        assert_eq!(inlines[0].text.as_deref(), Some("bold\ntext"));
20701        assert!(inlines[0]
20702            .marks
20703            .as_ref()
20704            .unwrap()
20705            .iter()
20706            .any(|m| m.mark_type == "strong"));
20707    }
20708
20709    #[test]
20710    fn trailing_newline_in_text_node_roundtrips() {
20711        // A text node ending with \n should round-trip preserving the
20712        // trailing newline.
20713        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"trailing\n"}]}]}"#;
20714        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20715        let md = adf_to_markdown(&doc).unwrap();
20716        assert!(
20717            md.contains("trailing\\n"),
20718            "trailing newline should be escaped: {md:?}"
20719        );
20720
20721        let rt = markdown_to_adf(&md).unwrap();
20722        let inlines = rt.content[0].content.as_ref().unwrap();
20723        assert_eq!(inlines.len(), 1);
20724        assert_eq!(inlines[0].text.as_deref(), Some("trailing\n"));
20725    }
20726
20727    #[test]
20728    fn hardbreak_and_embedded_newline_are_distinct() {
20729        // A hardBreak node and an embedded \n in a text node must not be
20730        // conflated — each must round-trip to its original form.
20731        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"}]}]}"#;
20732        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20733        let md = adf_to_markdown(&doc).unwrap();
20734        let rt = markdown_to_adf(&md).unwrap();
20735
20736        let inlines = rt.content[0].content.as_ref().unwrap();
20737        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20738        assert_eq!(
20739            types,
20740            vec!["text", "hardBreak", "text", "hardBreak", "text"]
20741        );
20742        assert_eq!(inlines[0].text.as_deref(), Some("before"));
20743        assert_eq!(inlines[2].text.as_deref(), Some("mid\ndle"));
20744        assert_eq!(inlines[4].text.as_deref(), Some("after"));
20745    }
20746
20747    // ---- Issue #472 tests ----
20748
20749    #[test]
20750    fn issue_472_bullet_list_trailing_hardbreak_roundtrips() {
20751        // Issue #472: trailing hardBreak at end of listItem paragraph must
20752        // not split the parent bulletList on round-trip.
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":"First item"},
20758                {"type":"hardBreak"}
20759              ]}
20760            ]},
20761            {"type":"listItem","content":[
20762              {"type":"paragraph","content":[
20763                {"type":"text","text":"Second item"}
20764              ]}
20765            ]}
20766          ]}
20767        ]}"#;
20768        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20769        let md = adf_to_markdown(&doc).unwrap();
20770        let rt = markdown_to_adf(&md).unwrap();
20771
20772        // Must remain a single bulletList
20773        assert_eq!(
20774            rt.content.len(),
20775            1,
20776            "Should be 1 block (bulletList), got {}",
20777            rt.content.len()
20778        );
20779        assert_eq!(rt.content[0].node_type, "bulletList");
20780        let items = rt.content[0].content.as_ref().unwrap();
20781        assert_eq!(
20782            items.len(),
20783            2,
20784            "Should have 2 listItems, got {}",
20785            items.len()
20786        );
20787
20788        // First item: text + hardBreak (trailing)
20789        let p1 = items[0].content.as_ref().unwrap()[0]
20790            .content
20791            .as_ref()
20792            .unwrap();
20793        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
20794        assert_eq!(types1, vec!["text", "hardBreak"]);
20795        assert_eq!(p1[0].text.as_deref(), Some("First item"));
20796
20797        // Second item: text only
20798        let p2 = items[1].content.as_ref().unwrap()[0]
20799            .content
20800            .as_ref()
20801            .unwrap();
20802        assert_eq!(p2[0].text.as_deref(), Some("Second item"));
20803    }
20804
20805    #[test]
20806    fn issue_472_ordered_list_trailing_hardbreak_roundtrips() {
20807        // Ordered list variant of issue #472.
20808        let adf_json = r#"{"version":1,"type":"doc","content":[
20809          {"type":"orderedList","attrs":{"order":1},"content":[
20810            {"type":"listItem","content":[
20811              {"type":"paragraph","content":[
20812                {"type":"text","text":"Alpha"},
20813                {"type":"hardBreak"}
20814              ]}
20815            ]},
20816            {"type":"listItem","content":[
20817              {"type":"paragraph","content":[
20818                {"type":"text","text":"Beta"}
20819              ]}
20820            ]}
20821          ]}
20822        ]}"#;
20823        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20824        let md = adf_to_markdown(&doc).unwrap();
20825        let rt = markdown_to_adf(&md).unwrap();
20826
20827        assert_eq!(rt.content.len(), 1);
20828        assert_eq!(rt.content[0].node_type, "orderedList");
20829        let items = rt.content[0].content.as_ref().unwrap();
20830        assert_eq!(items.len(), 2);
20831
20832        let p1 = items[0].content.as_ref().unwrap()[0]
20833            .content
20834            .as_ref()
20835            .unwrap();
20836        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
20837        assert_eq!(types1, vec!["text", "hardBreak"]);
20838        assert_eq!(p1[0].text.as_deref(), Some("Alpha"));
20839    }
20840
20841    #[test]
20842    fn issue_472_trailing_hardbreak_jfm_no_blank_line() {
20843        // The rendered JFM must not contain a blank line after the
20844        // trailing hardBreak — that would split the list.
20845        let adf_json = r#"{"version":1,"type":"doc","content":[
20846          {"type":"bulletList","content":[
20847            {"type":"listItem","content":[
20848              {"type":"paragraph","content":[
20849                {"type":"text","text":"Hello"},
20850                {"type":"hardBreak"}
20851              ]}
20852            ]},
20853            {"type":"listItem","content":[
20854              {"type":"paragraph","content":[
20855                {"type":"text","text":"World"}
20856              ]}
20857            ]}
20858          ]}
20859        ]}"#;
20860        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20861        let md = adf_to_markdown(&doc).unwrap();
20862
20863        // Should produce "- Hello\\n- World\n" (no blank line between items).
20864        assert_eq!(md, "- Hello\\\n- World\n");
20865    }
20866
20867    #[test]
20868    fn issue_472_multiple_trailing_hardbreaks_roundtrip() {
20869        // Multiple trailing hardBreaks at the end of a listItem paragraph.
20870        let adf_json = r#"{"version":1,"type":"doc","content":[
20871          {"type":"bulletList","content":[
20872            {"type":"listItem","content":[
20873              {"type":"paragraph","content":[
20874                {"type":"text","text":"Item"},
20875                {"type":"hardBreak"},
20876                {"type":"hardBreak"}
20877              ]}
20878            ]},
20879            {"type":"listItem","content":[
20880              {"type":"paragraph","content":[
20881                {"type":"text","text":"Next"}
20882              ]}
20883            ]}
20884          ]}
20885        ]}"#;
20886        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20887        let md = adf_to_markdown(&doc).unwrap();
20888        let rt = markdown_to_adf(&md).unwrap();
20889
20890        // Must remain a single bulletList
20891        assert_eq!(rt.content.len(), 1);
20892        assert_eq!(rt.content[0].node_type, "bulletList");
20893        let items = rt.content[0].content.as_ref().unwrap();
20894        assert_eq!(items.len(), 2);
20895
20896        // First item should preserve both hardBreaks
20897        let p1 = items[0].content.as_ref().unwrap()[0]
20898            .content
20899            .as_ref()
20900            .unwrap();
20901        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
20902        assert_eq!(types1, vec!["text", "hardBreak", "hardBreak"]);
20903    }
20904
20905    #[test]
20906    fn issue_472_hardbreak_mid_and_trailing_roundtrip() {
20907        // A hardBreak in the middle AND at the end of a listItem paragraph.
20908        let adf_json = r#"{"version":1,"type":"doc","content":[
20909          {"type":"bulletList","content":[
20910            {"type":"listItem","content":[
20911              {"type":"paragraph","content":[
20912                {"type":"text","text":"Line one"},
20913                {"type":"hardBreak"},
20914                {"type":"text","text":"Line two"},
20915                {"type":"hardBreak"}
20916              ]}
20917            ]},
20918            {"type":"listItem","content":[
20919              {"type":"paragraph","content":[
20920                {"type":"text","text":"Other item"}
20921              ]}
20922            ]}
20923          ]}
20924        ]}"#;
20925        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20926        let md = adf_to_markdown(&doc).unwrap();
20927        let rt = markdown_to_adf(&md).unwrap();
20928
20929        assert_eq!(rt.content.len(), 1);
20930        assert_eq!(rt.content[0].node_type, "bulletList");
20931        let items = rt.content[0].content.as_ref().unwrap();
20932        assert_eq!(items.len(), 2);
20933
20934        let p1 = items[0].content.as_ref().unwrap()[0]
20935            .content
20936            .as_ref()
20937            .unwrap();
20938        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
20939        assert_eq!(types1, vec!["text", "hardBreak", "text", "hardBreak"]);
20940        assert_eq!(p1[0].text.as_deref(), Some("Line one"));
20941        assert_eq!(p1[2].text.as_deref(), Some("Line two"));
20942    }
20943
20944    #[test]
20945    fn issue_472_only_hardbreak_in_listitem_paragraph() {
20946        // Edge case: paragraph contains only a hardBreak, no text.
20947        let adf_json = r#"{"version":1,"type":"doc","content":[
20948          {"type":"bulletList","content":[
20949            {"type":"listItem","content":[
20950              {"type":"paragraph","content":[
20951                {"type":"hardBreak"}
20952              ]}
20953            ]},
20954            {"type":"listItem","content":[
20955              {"type":"paragraph","content":[
20956                {"type":"text","text":"After"}
20957              ]}
20958            ]}
20959          ]}
20960        ]}"#;
20961        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20962        let md = adf_to_markdown(&doc).unwrap();
20963        let rt = markdown_to_adf(&md).unwrap();
20964
20965        // Must remain a single bulletList with 2 items
20966        assert_eq!(rt.content.len(), 1);
20967        assert_eq!(rt.content[0].node_type, "bulletList");
20968        let items = rt.content[0].content.as_ref().unwrap();
20969        assert_eq!(items.len(), 2);
20970    }
20971
20972    #[test]
20973    fn issue_472_three_items_middle_has_trailing_hardbreak() {
20974        // Three-item list where only the middle item has a trailing hardBreak.
20975        let adf_json = r#"{"version":1,"type":"doc","content":[
20976          {"type":"bulletList","content":[
20977            {"type":"listItem","content":[
20978              {"type":"paragraph","content":[
20979                {"type":"text","text":"First"}
20980              ]}
20981            ]},
20982            {"type":"listItem","content":[
20983              {"type":"paragraph","content":[
20984                {"type":"text","text":"Second"},
20985                {"type":"hardBreak"}
20986              ]}
20987            ]},
20988            {"type":"listItem","content":[
20989              {"type":"paragraph","content":[
20990                {"type":"text","text":"Third"}
20991              ]}
20992            ]}
20993          ]}
20994        ]}"#;
20995        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20996        let md = adf_to_markdown(&doc).unwrap();
20997        let rt = markdown_to_adf(&md).unwrap();
20998
20999        assert_eq!(rt.content.len(), 1);
21000        assert_eq!(rt.content[0].node_type, "bulletList");
21001        let items = rt.content[0].content.as_ref().unwrap();
21002        assert_eq!(items.len(), 3);
21003        assert_eq!(
21004            items[0].content.as_ref().unwrap()[0]
21005                .content
21006                .as_ref()
21007                .unwrap()[0]
21008                .text
21009                .as_deref(),
21010            Some("First")
21011        );
21012        assert_eq!(
21013            items[2].content.as_ref().unwrap()[0]
21014                .content
21015                .as_ref()
21016                .unwrap()[0]
21017                .text
21018                .as_deref(),
21019            Some("Third")
21020        );
21021    }
21022
21023    // ── Issue #494: trailing space-only text node after hardBreak ────
21024
21025    #[test]
21026    fn issue_494_space_after_hardbreak_roundtrip() {
21027        // The original reproducer from issue #494: a single space text
21028        // node following a hardBreak is silently dropped on round-trip.
21029        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21030          {"type":"text","text":"Some text"},
21031          {"type":"hardBreak"},
21032          {"type":"text","text":" "}
21033        ]}]}"#;
21034        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21035        let md = adf_to_markdown(&doc).unwrap();
21036        let rt = markdown_to_adf(&md).unwrap();
21037        let inlines = rt.content[0].content.as_ref().unwrap();
21038        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21039        assert_eq!(
21040            types,
21041            vec!["text", "hardBreak", "text"],
21042            "space-only text node after hardBreak should survive round-trip"
21043        );
21044        assert_eq!(inlines[2].text.as_deref(), Some(" "));
21045    }
21046
21047    #[test]
21048    fn issue_494_multiple_spaces_after_hardbreak_roundtrip() {
21049        // Multiple spaces after hardBreak should also survive.
21050        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21051          {"type":"text","text":"Hello"},
21052          {"type":"hardBreak"},
21053          {"type":"text","text":"   "}
21054        ]}]}"#;
21055        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21056        let md = adf_to_markdown(&doc).unwrap();
21057        let rt = markdown_to_adf(&md).unwrap();
21058        let inlines = rt.content[0].content.as_ref().unwrap();
21059        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21060        assert_eq!(
21061            types,
21062            vec!["text", "hardBreak", "text"],
21063            "multi-space text node after hardBreak should survive round-trip"
21064        );
21065        assert_eq!(inlines[2].text.as_deref(), Some("   "));
21066    }
21067
21068    #[test]
21069    fn issue_494_space_then_text_after_hardbreak_roundtrip() {
21070        // Space followed by real text after hardBreak — the space should
21071        // be preserved as part of the text node.
21072        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21073          {"type":"text","text":"Before"},
21074          {"type":"hardBreak"},
21075          {"type":"text","text":" After"}
21076        ]}]}"#;
21077        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21078        let md = adf_to_markdown(&doc).unwrap();
21079        let rt = markdown_to_adf(&md).unwrap();
21080        let inlines = rt.content[0].content.as_ref().unwrap();
21081        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21082        assert_eq!(types, vec!["text", "hardBreak", "text"]);
21083        assert_eq!(inlines[2].text.as_deref(), Some(" After"));
21084    }
21085
21086    #[test]
21087    fn issue_494_hardbreak_then_space_then_hardbreak_roundtrip() {
21088        // Space sandwiched between two hardBreaks.
21089        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21090          {"type":"text","text":"A"},
21091          {"type":"hardBreak"},
21092          {"type":"text","text":" "},
21093          {"type":"hardBreak"},
21094          {"type":"text","text":"B"}
21095        ]}]}"#;
21096        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21097        let md = adf_to_markdown(&doc).unwrap();
21098        let rt = markdown_to_adf(&md).unwrap();
21099        let inlines = rt.content[0].content.as_ref().unwrap();
21100        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21101        assert_eq!(
21102            types,
21103            vec!["text", "hardBreak", "text", "hardBreak", "text"],
21104            "space between two hardBreaks should survive round-trip"
21105        );
21106        assert_eq!(inlines[2].text.as_deref(), Some(" "));
21107        assert_eq!(inlines[4].text.as_deref(), Some("B"));
21108    }
21109
21110    #[test]
21111    fn issue_494_trailing_space_hardbreak_style_not_confused() {
21112        // A plain paragraph break (blank line) should still work after
21113        // a line that does NOT end with a hardBreak marker.
21114        let md = "first paragraph\n\nsecond paragraph\n";
21115        let doc = markdown_to_adf(md).unwrap();
21116        assert_eq!(
21117            doc.content.len(),
21118            2,
21119            "blank line should still separate paragraphs"
21120        );
21121    }
21122
21123    #[test]
21124    fn issue_494_space_after_trailing_space_hardbreak_roundtrip() {
21125        // Same bug but with trailing-space style hardBreak (two spaces
21126        // before newline) instead of backslash style.
21127        let md = "line one  \n   \n";
21128        // The above is: "line one" + trailing-space hardBreak + continuation
21129        // line "   " (2-space indent + 1 space content).  The space-only
21130        // continuation should not be treated as a blank paragraph break.
21131        let doc = markdown_to_adf(md).unwrap();
21132        let inlines = doc.content[0].content.as_ref().unwrap();
21133        let has_text_after_break = inlines.iter().any(|n| {
21134            n.node_type == "text"
21135                && n.text
21136                    .as_deref()
21137                    .is_some_and(|t| t.trim().is_empty() && !t.is_empty())
21138        });
21139        assert!(
21140            has_text_after_break || inlines.len() >= 2,
21141            "space-only line after trailing-space hardBreak should be preserved"
21142        );
21143    }
21144
21145    #[test]
21146    fn issue_494_space_after_hardbreak_in_list_item_roundtrip() {
21147        // Exercises the same bug inside a list item context.
21148        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
21149          {"type":"listItem","content":[{"type":"paragraph","content":[
21150            {"type":"text","text":"item"},
21151            {"type":"hardBreak"},
21152            {"type":"text","text":" "}
21153          ]}]}
21154        ]}]}"#;
21155        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21156        let md = adf_to_markdown(&doc).unwrap();
21157        let rt = markdown_to_adf(&md).unwrap();
21158        let list = &rt.content[0];
21159        let item = &list.content.as_ref().unwrap()[0];
21160        let para = &item.content.as_ref().unwrap()[0];
21161        let inlines = para.content.as_ref().unwrap();
21162        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21163        assert_eq!(
21164            types,
21165            vec!["text", "hardBreak", "text"],
21166            "space after hardBreak in list item should survive round-trip"
21167        );
21168        assert_eq!(inlines[2].text.as_deref(), Some(" "));
21169    }
21170
21171    // ── Issue #510: trailing spaces in text node should not become hardBreak ──
21172
21173    #[test]
21174    fn issue_510_trailing_double_space_paragraph_roundtrip() {
21175        // Two trailing spaces in a text node must survive round-trip without
21176        // being converted to a hardBreak or merging the next paragraph.
21177        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"}]}]}"#;
21178        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21179        let md = adf_to_markdown(&doc).unwrap();
21180        let rt = markdown_to_adf(&md).unwrap();
21181
21182        // Must produce two separate paragraphs
21183        assert_eq!(
21184            rt.content.len(),
21185            2,
21186            "should produce two paragraphs, got: {}",
21187            rt.content.len()
21188        );
21189        assert_eq!(rt.content[0].node_type, "paragraph");
21190        assert_eq!(rt.content[1].node_type, "paragraph");
21191
21192        // First paragraph text preserves trailing spaces
21193        let p1 = rt.content[0].content.as_ref().unwrap();
21194        assert_eq!(
21195            p1[0].text.as_deref(),
21196            Some("first paragraph with trailing spaces  "),
21197            "trailing spaces should be preserved in first paragraph"
21198        );
21199
21200        // Second paragraph is intact
21201        let p2 = rt.content[1].content.as_ref().unwrap();
21202        assert_eq!(p2[0].text.as_deref(), Some("second paragraph"));
21203
21204        // No hardBreak nodes should exist
21205        let all_types: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
21206        assert!(
21207            !all_types.contains(&"hardBreak"),
21208            "trailing spaces should not produce hardBreak, got: {all_types:?}"
21209        );
21210    }
21211
21212    #[test]
21213    fn issue_510_trailing_triple_space_roundtrip() {
21214        // Three trailing spaces also must not become a hardBreak.
21215        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"text   "}]},{"type":"paragraph","content":[{"type":"text","text":"next"}]}]}"#;
21216        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21217        let md = adf_to_markdown(&doc).unwrap();
21218        let rt = markdown_to_adf(&md).unwrap();
21219
21220        assert_eq!(rt.content.len(), 2, "should still be two paragraphs");
21221        let p1 = rt.content[0].content.as_ref().unwrap();
21222        assert_eq!(
21223            p1[0].text.as_deref(),
21224            Some("text   "),
21225            "three trailing spaces should be preserved"
21226        );
21227    }
21228
21229    #[test]
21230    fn issue_510_trailing_spaces_with_backslash_roundtrip() {
21231        // Text ending with backslash + trailing spaces: both must survive.
21232        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"end\\  "}]}]}"#;
21233        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21234        let md = adf_to_markdown(&doc).unwrap();
21235        let rt = markdown_to_adf(&md).unwrap();
21236        let p = rt.content[0].content.as_ref().unwrap();
21237        assert_eq!(
21238            p[0].text.as_deref(),
21239            Some("end\\  "),
21240            "backslash + trailing spaces should both survive"
21241        );
21242    }
21243
21244    #[test]
21245    fn issue_510_jfm_contains_escaped_trailing_space() {
21246        // Verify the serializer actually emits the backslash-space escape.
21247        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello  "}]}]}"#;
21248        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21249        let md = adf_to_markdown(&doc).unwrap();
21250        assert!(
21251            md.contains(r"\ "),
21252            "JFM should contain backslash-space escape for trailing spaces, got: {md:?}"
21253        );
21254        // Must NOT end with two plain spaces before newline
21255        for line in md.lines() {
21256            assert!(
21257                !line.ends_with("  "),
21258                "no JFM line should end with two plain spaces, got: {line:?}"
21259            );
21260        }
21261    }
21262
21263    #[test]
21264    fn issue_510_single_trailing_space_not_escaped() {
21265        // A single trailing space should NOT be escaped (not a hardBreak trigger).
21266        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"word "}]}]}"#;
21267        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21268        let md = adf_to_markdown(&doc).unwrap();
21269        assert!(
21270            !md.contains('\\'),
21271            "single trailing space should not be escaped, got: {md:?}"
21272        );
21273        let rt = markdown_to_adf(&md).unwrap();
21274        let p = rt.content[0].content.as_ref().unwrap();
21275        assert_eq!(p[0].text.as_deref(), Some("word "));
21276    }
21277
21278    #[test]
21279    fn issue_510_trailing_spaces_in_heading_roundtrip() {
21280        // Trailing double-spaces in a heading text node should also survive.
21281        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"heading  "}]}]}"#;
21282        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21283        let md = adf_to_markdown(&doc).unwrap();
21284        let rt = markdown_to_adf(&md).unwrap();
21285        let h = rt.content[0].content.as_ref().unwrap();
21286        assert_eq!(
21287            h[0].text.as_deref(),
21288            Some("heading  "),
21289            "trailing spaces in heading should be preserved"
21290        );
21291    }
21292
21293    #[test]
21294    fn issue_510_trailing_spaces_in_list_item_roundtrip() {
21295        // Trailing double-spaces in a bullet list item text node.
21296        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"item  "}]}]}]}]}"#;
21297        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21298        let md = adf_to_markdown(&doc).unwrap();
21299        let rt = markdown_to_adf(&md).unwrap();
21300        let list = &rt.content[0];
21301        let item = &list.content.as_ref().unwrap()[0];
21302        let para = &item.content.as_ref().unwrap()[0];
21303        let inlines = para.content.as_ref().unwrap();
21304        assert_eq!(
21305            inlines[0].text.as_deref(),
21306            Some("item  "),
21307            "trailing spaces in list item should be preserved"
21308        );
21309    }
21310
21311    #[test]
21312    fn issue_510_trailing_spaces_with_bold_mark_roundtrip() {
21313        // Trailing spaces in a bold-marked text node: the closing **
21314        // comes after the spaces, so the line doesn't end with spaces.
21315        // But the escape should still be applied (and be harmless).
21316        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"bold  ","marks":[{"type":"strong"}]}]}]}"#;
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        let p = rt.content[0].content.as_ref().unwrap();
21321        assert_eq!(
21322            p[0].text.as_deref(),
21323            Some("bold  "),
21324            "trailing spaces in bold text should be preserved"
21325        );
21326    }
21327
21328    #[test]
21329    fn issue_510_hardbreak_between_paragraphs_still_works() {
21330        // Actual hardBreak nodes must still round-trip correctly.
21331        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"line one"},{"type":"hardBreak"},{"type":"text","text":"line two"}]}]}"#;
21332        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21333        let md = adf_to_markdown(&doc).unwrap();
21334        let rt = markdown_to_adf(&md).unwrap();
21335        let inlines = rt.content[0].content.as_ref().unwrap();
21336        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21337        assert_eq!(
21338            types,
21339            vec!["text", "hardBreak", "text"],
21340            "explicit hardBreak should still round-trip"
21341        );
21342    }
21343
21344    #[test]
21345    fn issue_510_all_spaces_text_node_roundtrip() {
21346        // A text node that is entirely spaces (2+) should survive.
21347        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"  "}]}]}"#;
21348        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21349        let md = adf_to_markdown(&doc).unwrap();
21350        let rt = markdown_to_adf(&md).unwrap();
21351        let p = rt.content[0].content.as_ref().unwrap();
21352        assert_eq!(
21353            p[0].text.as_deref(),
21354            Some("  "),
21355            "space-only text node should survive round-trip"
21356        );
21357    }
21358
21359    // ── Issue #522: listItem multi-paragraph merge ──────────────────────
21360
21361    #[test]
21362    fn issue_522_listitem_hardbreak_then_two_paragraphs_roundtrips() {
21363        // The exact reproducer from issue #522: first paragraph has
21364        // hardBreak nodes, followed by two sibling paragraphs.
21365        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"}]}]}]}]}"#;
21366        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21367        let md = adf_to_markdown(&doc).unwrap();
21368        let rt = markdown_to_adf(&md).unwrap();
21369
21370        let items = rt.content[0].content.as_ref().unwrap();
21371        assert_eq!(items.len(), 1);
21372        let children = items[0].content.as_ref().unwrap();
21373        assert_eq!(
21374            children.len(),
21375            3,
21376            "Expected 3 paragraphs in listItem, got {}",
21377            children.len()
21378        );
21379        assert_eq!(children[0].node_type, "paragraph");
21380        assert_eq!(children[1].node_type, "paragraph");
21381        assert_eq!(children[2].node_type, "paragraph");
21382
21383        // Verify the text content of each paragraph
21384        let text1 = children[1].content.as_ref().unwrap()[0]
21385            .text
21386            .as_deref()
21387            .unwrap();
21388        assert_eq!(text1, "second paragraph");
21389        let text2 = children[2].content.as_ref().unwrap()[0]
21390            .text
21391            .as_deref()
21392            .unwrap();
21393        assert_eq!(text2, "third paragraph");
21394    }
21395
21396    #[test]
21397    fn issue_522_ordered_list_hardbreak_then_paragraphs_roundtrips() {
21398        // Same scenario in an ordered list.
21399        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"}]}]}]}]}"#;
21400        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21401        let md = adf_to_markdown(&doc).unwrap();
21402        let rt = markdown_to_adf(&md).unwrap();
21403
21404        let items = rt.content[0].content.as_ref().unwrap();
21405        let children = items[0].content.as_ref().unwrap();
21406        assert_eq!(
21407            children.len(),
21408            3,
21409            "Expected 3 paragraphs in ordered listItem, got {}",
21410            children.len()
21411        );
21412        assert_eq!(children[1].node_type, "paragraph");
21413        assert_eq!(children[2].node_type, "paragraph");
21414        assert_eq!(
21415            children[1].content.as_ref().unwrap()[0]
21416                .text
21417                .as_deref()
21418                .unwrap(),
21419            "second para"
21420        );
21421        assert_eq!(
21422            children[2].content.as_ref().unwrap()[0]
21423                .text
21424                .as_deref()
21425                .unwrap(),
21426            "third para"
21427        );
21428    }
21429
21430    #[test]
21431    fn issue_522_two_paragraphs_without_hardbreak_roundtrips() {
21432        // Two paragraphs without hardBreak — should also remain separate.
21433        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"}]}]}]}]}"#;
21434        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21435        let md = adf_to_markdown(&doc).unwrap();
21436        let rt = markdown_to_adf(&md).unwrap();
21437
21438        let items = rt.content[0].content.as_ref().unwrap();
21439        let children = items[0].content.as_ref().unwrap();
21440        assert_eq!(
21441            children.len(),
21442            2,
21443            "Expected 2 paragraphs in listItem, got {}",
21444            children.len()
21445        );
21446        assert_eq!(children[0].node_type, "paragraph");
21447        assert_eq!(children[1].node_type, "paragraph");
21448    }
21449
21450    #[test]
21451    fn issue_522_paragraph_then_nested_list_no_spurious_blank() {
21452        // A paragraph followed by a nested list should NOT get a blank
21453        // separator (only paragraph-paragraph transitions need one).
21454        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"}]}]}]}]}]}]}"#;
21455        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21456        let md = adf_to_markdown(&doc).unwrap();
21457        // Should not contain a blank indented line between parent text and sub-list
21458        assert!(
21459            !md.contains("  \n  -"),
21460            "No blank separator between paragraph and nested list"
21461        );
21462        let rt = markdown_to_adf(&md).unwrap();
21463
21464        let items = rt.content[0].content.as_ref().unwrap();
21465        let children = items[0].content.as_ref().unwrap();
21466        assert_eq!(children.len(), 2);
21467        assert_eq!(children[0].node_type, "paragraph");
21468        assert_eq!(children[1].node_type, "bulletList");
21469    }
21470
21471    #[test]
21472    fn issue_522_three_paragraphs_no_hardbreak_roundtrips() {
21473        // Three plain paragraphs (no hardBreak) inside a single listItem.
21474        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"}]}]}]}]}"#;
21475        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21476        let md = adf_to_markdown(&doc).unwrap();
21477        let rt = markdown_to_adf(&md).unwrap();
21478
21479        let items = rt.content[0].content.as_ref().unwrap();
21480        let children = items[0].content.as_ref().unwrap();
21481        assert_eq!(
21482            children.len(),
21483            3,
21484            "Expected 3 paragraphs, got {}",
21485            children.len()
21486        );
21487        for (i, child) in children.iter().enumerate() {
21488            assert_eq!(
21489                child.node_type, "paragraph",
21490                "Child {i} should be a paragraph"
21491            );
21492        }
21493    }
21494
21495    #[test]
21496    fn issue_522_multiple_list_items_each_with_paragraphs() {
21497        // Multiple list items, each with multiple paragraphs.
21498        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"}]}]}]}]}"#;
21499        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21500        let md = adf_to_markdown(&doc).unwrap();
21501        let rt = markdown_to_adf(&md).unwrap();
21502
21503        let items = rt.content[0].content.as_ref().unwrap();
21504        assert_eq!(items.len(), 2, "Expected 2 list items");
21505
21506        let item1 = items[0].content.as_ref().unwrap();
21507        assert_eq!(item1.len(), 2, "Item 1 should have 2 paragraphs");
21508
21509        let item2 = items[1].content.as_ref().unwrap();
21510        assert_eq!(item2.len(), 2, "Item 2 should have 2 paragraphs");
21511        // Verify hardBreak is preserved in item2's first paragraph
21512        let item2_p1_inlines = item2[0].content.as_ref().unwrap();
21513        let types: Vec<&str> = item2_p1_inlines
21514            .iter()
21515            .map(|n| n.node_type.as_str())
21516            .collect();
21517        assert_eq!(types, vec!["text", "hardBreak", "text"]);
21518    }
21519
21520    #[test]
21521    fn issue_531_blockquote_hardbreak_then_two_paragraphs_roundtrips() {
21522        // The exact reproducer from issue #531: blockquote with first
21523        // paragraph containing hardBreak nodes, followed by two sibling
21524        // paragraphs.
21525        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"}]}]}]}"#;
21526        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21527        let md = adf_to_markdown(&doc).unwrap();
21528        let rt = markdown_to_adf(&md).unwrap();
21529
21530        let children = rt.content[0].content.as_ref().unwrap();
21531        assert_eq!(
21532            children.len(),
21533            3,
21534            "Expected 3 paragraphs in blockquote, got {}",
21535            children.len()
21536        );
21537        assert_eq!(children[0].node_type, "paragraph");
21538        assert_eq!(children[1].node_type, "paragraph");
21539        assert_eq!(children[2].node_type, "paragraph");
21540
21541        let text1 = children[1].content.as_ref().unwrap()[0]
21542            .text
21543            .as_deref()
21544            .unwrap();
21545        assert_eq!(text1, "second paragraph");
21546        let text2 = children[2].content.as_ref().unwrap()[0]
21547            .text
21548            .as_deref()
21549            .unwrap();
21550        assert_eq!(text2, "third paragraph");
21551    }
21552
21553    #[test]
21554    fn issue_531_blockquote_two_paragraphs_without_hardbreak_roundtrips() {
21555        // Two simple paragraphs inside a blockquote, no hardBreak.
21556        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"}]}]}]}"#;
21557        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21558        let md = adf_to_markdown(&doc).unwrap();
21559        let rt = markdown_to_adf(&md).unwrap();
21560
21561        let children = rt.content[0].content.as_ref().unwrap();
21562        assert_eq!(
21563            children.len(),
21564            2,
21565            "Expected 2 paragraphs in blockquote, got {}",
21566            children.len()
21567        );
21568        assert_eq!(children[0].node_type, "paragraph");
21569        assert_eq!(children[1].node_type, "paragraph");
21570    }
21571
21572    #[test]
21573    fn issue_531_blockquote_three_paragraphs_no_hardbreak_roundtrips() {
21574        // Three paragraphs, none with hardBreak.
21575        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"}]}]}]}"#;
21576        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21577        let md = adf_to_markdown(&doc).unwrap();
21578        let rt = markdown_to_adf(&md).unwrap();
21579
21580        let children = rt.content[0].content.as_ref().unwrap();
21581        assert_eq!(
21582            children.len(),
21583            3,
21584            "Expected 3 paragraphs in blockquote, got {}",
21585            children.len()
21586        );
21587        for child in children {
21588            assert_eq!(child.node_type, "paragraph");
21589        }
21590    }
21591
21592    #[test]
21593    fn issue_531_blockquote_paragraph_then_list_no_spurious_blank() {
21594        // A paragraph followed by a nested list inside a blockquote —
21595        // should NOT insert a blank separator line.
21596        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"}]}]}]}]}]}"#;
21597        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21598        let md = adf_to_markdown(&doc).unwrap();
21599        let rt = markdown_to_adf(&md).unwrap();
21600
21601        let children = rt.content[0].content.as_ref().unwrap();
21602        assert_eq!(children[0].node_type, "paragraph");
21603        assert_eq!(children[1].node_type, "bulletList");
21604    }
21605
21606    #[test]
21607    fn issue_531_blockquote_single_paragraph_unchanged() {
21608        // A single paragraph in a blockquote should remain unchanged.
21609        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"solo"}]}]}]}"#;
21610        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21611        let md = adf_to_markdown(&doc).unwrap();
21612        let rt = markdown_to_adf(&md).unwrap();
21613
21614        let children = rt.content[0].content.as_ref().unwrap();
21615        assert_eq!(children.len(), 1);
21616        assert_eq!(children[0].node_type, "paragraph");
21617        let text = children[0].content.as_ref().unwrap()[0]
21618            .text
21619            .as_deref()
21620            .unwrap();
21621        assert_eq!(text, "solo");
21622    }
21623
21624    // ── Issue #554: marks combined with `code` or with each other ──────
21625
21626    /// Helper: roundtrip an ADF document and assert the marks on the first
21627    /// text node match `expected_marks` (in order).
21628    fn assert_roundtrip_marks(adf_json: &str, expected_marks: &[&str]) {
21629        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21630        let md = adf_to_markdown(&doc).unwrap();
21631        let rt = markdown_to_adf(&md).unwrap();
21632        let node = &rt.content[0].content.as_ref().unwrap()[0];
21633        let mark_types: Vec<&str> = node
21634            .marks
21635            .as_ref()
21636            .expect("should have marks")
21637            .iter()
21638            .map(|m| m.mark_type.as_str())
21639            .collect();
21640        assert_eq!(
21641            mark_types, expected_marks,
21642            "mark order mismatch for md={md}"
21643        );
21644    }
21645
21646    #[test]
21647    fn issue_554_code_and_text_color_preserved() {
21648        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21649          {"type":"text","text":"x","marks":[
21650            {"type":"textColor","attrs":{"color":"#008000"}},
21651            {"type":"code"}
21652          ]}
21653        ]}]}"##;
21654        assert_roundtrip_marks(adf_json, &["textColor", "code"]);
21655    }
21656
21657    #[test]
21658    fn issue_554_code_and_bg_color_preserved() {
21659        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21660          {"type":"text","text":"x","marks":[
21661            {"type":"backgroundColor","attrs":{"color":"#FF0000"}},
21662            {"type":"code"}
21663          ]}
21664        ]}]}"##;
21665        assert_roundtrip_marks(adf_json, &["backgroundColor", "code"]);
21666    }
21667
21668    #[test]
21669    fn issue_554_code_and_subsup_preserved() {
21670        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21671          {"type":"text","text":"x","marks":[
21672            {"type":"subsup","attrs":{"type":"sub"}},
21673            {"type":"code"}
21674          ]}
21675        ]}]}"#;
21676        assert_roundtrip_marks(adf_json, &["subsup", "code"]);
21677    }
21678
21679    #[test]
21680    fn issue_554_code_and_underline_preserved() {
21681        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21682          {"type":"text","text":"x","marks":[
21683            {"type":"underline"},
21684            {"type":"code"}
21685          ]}
21686        ]}]}"#;
21687        assert_roundtrip_marks(adf_json, &["underline", "code"]);
21688    }
21689
21690    #[test]
21691    fn issue_554_code_textcolor_and_underline_preserved() {
21692        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21693          {"type":"text","text":"x","marks":[
21694            {"type":"textColor","attrs":{"color":"#008000"}},
21695            {"type":"underline"},
21696            {"type":"code"}
21697          ]}
21698        ]}]}"##;
21699        assert_roundtrip_marks(adf_json, &["textColor", "underline", "code"]);
21700    }
21701
21702    #[test]
21703    fn issue_554_textcolor_and_underline_preserved() {
21704        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21705          {"type":"text","text":"x","marks":[
21706            {"type":"textColor","attrs":{"color":"#008000"}},
21707            {"type":"underline"}
21708          ]}
21709        ]}]}"##;
21710        assert_roundtrip_marks(adf_json, &["textColor", "underline"]);
21711    }
21712
21713    #[test]
21714    fn issue_554_underline_and_textcolor_preserved_order_swapped() {
21715        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21716          {"type":"text","text":"x","marks":[
21717            {"type":"underline"},
21718            {"type":"textColor","attrs":{"color":"#008000"}}
21719          ]}
21720        ]}]}"##;
21721        // underline appears first, so it should be the OUTER wrapper.
21722        assert_roundtrip_marks(adf_json, &["underline", "textColor"]);
21723    }
21724
21725    #[test]
21726    fn issue_554_textcolor_and_annotation_preserved() {
21727        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21728          {"type":"text","text":"x","marks":[
21729            {"type":"textColor","attrs":{"color":"#008000"}},
21730            {"type":"annotation","attrs":{"id":"abc-123","annotationType":"inlineComment"}}
21731          ]}
21732        ]}]}"##;
21733        assert_roundtrip_marks(adf_json, &["textColor", "annotation"]);
21734    }
21735
21736    #[test]
21737    fn issue_554_bgcolor_and_underline_preserved() {
21738        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21739          {"type":"text","text":"x","marks":[
21740            {"type":"backgroundColor","attrs":{"color":"#FF0000"}},
21741            {"type":"underline"}
21742          ]}
21743        ]}]}"##;
21744        assert_roundtrip_marks(adf_json, &["backgroundColor", "underline"]);
21745    }
21746
21747    #[test]
21748    fn issue_554_subsup_and_underline_preserved() {
21749        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21750          {"type":"text","text":"x","marks":[
21751            {"type":"subsup","attrs":{"type":"sub"}},
21752            {"type":"underline"}
21753          ]}
21754        ]}]}"#;
21755        assert_roundtrip_marks(adf_json, &["subsup", "underline"]);
21756    }
21757
21758    #[test]
21759    fn issue_554_exact_reproducer_full_match() {
21760        // The exact reproducer from issue #554. The byte-for-byte ADF JSON
21761        // must round-trip through `from-adf | to-adf` unchanged.
21762        let adf_json = r##"{
21763          "version": 1,
21764          "type": "doc",
21765          "content": [
21766            {
21767              "type": "paragraph",
21768              "content": [
21769                {"type":"text","text":"Status: ","marks":[{"type":"strong"}]},
21770                {"type":"text","text":"Approved","marks":[
21771                  {"type":"textColor","attrs":{"color":"#008000"}}
21772                ]},
21773                {"type":"text","text":" — ready to proceed"}
21774              ]
21775            }
21776          ]
21777        }"##;
21778        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21779        let md = adf_to_markdown(&doc).unwrap();
21780        assert!(
21781            md.contains(":span[Approved]{color=#008000}"),
21782            "JFM should contain green span: {md}"
21783        );
21784        let rt = markdown_to_adf(&md).unwrap();
21785        // Find the "Approved" text node and verify color is preserved.
21786        let approved = rt.content[0]
21787            .content
21788            .as_ref()
21789            .unwrap()
21790            .iter()
21791            .find(|n| n.text.as_deref() == Some("Approved"))
21792            .expect("Approved text node");
21793        let marks = approved.marks.as_ref().expect("should have marks");
21794        let color_mark = marks
21795            .iter()
21796            .find(|m| m.mark_type == "textColor")
21797            .expect("textColor mark must be preserved");
21798        assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#008000");
21799    }
21800
21801    #[test]
21802    fn issue_554_textcolor_with_code_renders_span_around_code() {
21803        // Verify the rendered JFM uses `:span[`text`]{color=...}` — the
21804        // syntax suggested in the issue.
21805        let doc = AdfDocument {
21806            version: 1,
21807            doc_type: "doc".to_string(),
21808            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
21809                "fn main",
21810                vec![
21811                    AdfMark::text_color("#008000"),
21812                    AdfMark {
21813                        mark_type: "code".to_string(),
21814                        attrs: None,
21815                    },
21816                ],
21817            )])],
21818        };
21819        let md = adf_to_markdown(&doc).unwrap();
21820        assert!(
21821            md.contains(":span[`fn main`]{color=#008000}"),
21822            "expected span-wrapped code, got: {md}"
21823        );
21824    }
21825
21826    #[test]
21827    fn issue_554_underline_with_code_renders_bracketed_around_code() {
21828        let doc = AdfDocument {
21829            version: 1,
21830            doc_type: "doc".to_string(),
21831            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
21832                "fn main",
21833                vec![
21834                    AdfMark::underline(),
21835                    AdfMark {
21836                        mark_type: "code".to_string(),
21837                        attrs: None,
21838                    },
21839                ],
21840            )])],
21841        };
21842        let md = adf_to_markdown(&doc).unwrap();
21843        assert!(
21844            md.contains("[`fn main`]{underline}"),
21845            "expected bracketed-span around code, got: {md}"
21846        );
21847    }
21848
21849    // ── Issue #554 (re-opened): boundary-underscore destroys span directives ──
21850
21851    #[test]
21852    fn issue_554_underscore_adjacent_to_textcolor_span_roundtrip() {
21853        // Reproducer from the re-opened issue: a `_ ` plain-text node followed
21854        // by a textColor span whose text starts with `_` produced JFM that the
21855        // parser saw as an italic delimiter pair, destroying the span and
21856        // losing the textColor mark entirely.
21857        let adf_json = r##"{
21858          "version": 1,
21859          "type": "doc",
21860          "content": [
21861            {
21862              "type": "paragraph",
21863              "content": [
21864                {"type":"text","text":"_ "},
21865                {"type":"text","text":"_Action:*","marks":[
21866                  {"type":"textColor","attrs":{"color":"#008000"}}
21867                ]},
21868                {"type":"text","text":" Complete the setup process."}
21869              ]
21870            }
21871          ]
21872        }"##;
21873        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21874        let md = adf_to_markdown(&doc).unwrap();
21875        // The leading `_` chars must be backslash-escaped so the parser
21876        // doesn't form a false italic pair across the span boundary.
21877        assert!(
21878            md.contains(r"\_ ") && md.contains(r":span[\_Action"),
21879            "underscores at node boundaries should be escaped: {md}"
21880        );
21881        let rt = markdown_to_adf(&md).unwrap();
21882        let para_content = rt.content[0].content.as_ref().unwrap();
21883        // Find the textColor-marked node.
21884        let colored = para_content
21885            .iter()
21886            .find(|n| {
21887                n.marks
21888                    .as_deref()
21889                    .is_some_and(|ms| ms.iter().any(|m| m.mark_type == "textColor"))
21890            })
21891            .expect("textColor node must be preserved");
21892        assert_eq!(colored.text.as_deref(), Some("_Action:*"));
21893        let color_mark = colored
21894            .marks
21895            .as_ref()
21896            .unwrap()
21897            .iter()
21898            .find(|m| m.mark_type == "textColor")
21899            .unwrap();
21900        assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#008000");
21901        // Verify no spurious em mark crept in.
21902        for n in para_content {
21903            if let Some(ms) = n.marks.as_deref() {
21904                assert!(
21905                    !ms.iter().any(|m| m.mark_type == "em"),
21906                    "no em mark should appear, got marks {:?}",
21907                    ms.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
21908                );
21909            }
21910        }
21911    }
21912
21913    #[test]
21914    fn issue_554_underscore_intraword_left_unescaped() {
21915        // Sanity check: ordinary intraword underscores like `do_something_useful`
21916        // should NOT be escaped — escaping would still round-trip correctly,
21917        // but produces noisy backslashes in the JFM output.
21918        let doc = AdfDocument {
21919            version: 1,
21920            doc_type: "doc".to_string(),
21921            content: vec![AdfNode::paragraph(vec![AdfNode::text(
21922                "call do_something_useful now",
21923            )])],
21924        };
21925        let md = adf_to_markdown(&doc).unwrap();
21926        assert!(
21927            md.contains("do_something_useful") && !md.contains(r"do\_something\_useful"),
21928            "intraword underscores should not be escaped: {md}"
21929        );
21930    }
21931
21932    #[test]
21933    fn issue_554_code_underline_then_textcolor_bracketed_outer() {
21934        // Mark order [underline, textColor, code] — bracketed-span outer,
21935        // span inner. Exercises wrap_with_attrs (true, true) !span_before.
21936        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21937          {"type":"text","text":"x","marks":[
21938            {"type":"underline"},
21939            {"type":"textColor","attrs":{"color":"#008000"}},
21940            {"type":"code"}
21941          ]}
21942        ]}]}"##;
21943        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21944        let md = adf_to_markdown(&doc).unwrap();
21945        // Bracketed-span should be the outermost wrapper.
21946        assert!(
21947            md.starts_with('[') && md.contains("underline}"),
21948            "bracketed-span should wrap the span, got: {md}"
21949        );
21950        let rt = markdown_to_adf(&md).unwrap();
21951        let node = &rt.content[0].content.as_ref().unwrap()[0];
21952        let mark_types: Vec<&str> = node
21953            .marks
21954            .as_ref()
21955            .unwrap()
21956            .iter()
21957            .map(|m| m.mark_type.as_str())
21958            .collect();
21959        assert_eq!(mark_types, vec!["underline", "textColor", "code"]);
21960    }
21961
21962    #[test]
21963    fn issue_554_textcolor_underline_link_all_preserved() {
21964        // Mark order [textColor, underline, link] — span outer, bracketed
21965        // wraps the link inside. Exercises the span-wraps-link-with-bracketed
21966        // branch.
21967        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21968          {"type":"text","text":"linked","marks":[
21969            {"type":"textColor","attrs":{"color":"#008000"}},
21970            {"type":"underline"},
21971            {"type":"link","attrs":{"href":"https://example.com"}}
21972          ]}
21973        ]}]}"##;
21974        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21975        let md = adf_to_markdown(&doc).unwrap();
21976        let rt = markdown_to_adf(&md).unwrap();
21977        let node = &rt.content[0].content.as_ref().unwrap()[0];
21978        let mark_types: Vec<&str> = node
21979            .marks
21980            .as_ref()
21981            .unwrap()
21982            .iter()
21983            .map(|m| m.mark_type.as_str())
21984            .collect();
21985        assert_eq!(mark_types, vec!["textColor", "underline", "link"]);
21986    }
21987
21988    #[test]
21989    fn issue_554_underline_textcolor_link_bracketed_outer_link_last() {
21990        // Mark order [underline, textColor, link] — bracketed-span outer of
21991        // both span and link. Exercises the bracketed-wraps-everything branch.
21992        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21993          {"type":"text","text":"linked","marks":[
21994            {"type":"underline"},
21995            {"type":"textColor","attrs":{"color":"#008000"}},
21996            {"type":"link","attrs":{"href":"https://example.com"}}
21997          ]}
21998        ]}]}"##;
21999        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22000        let md = adf_to_markdown(&doc).unwrap();
22001        let rt = markdown_to_adf(&md).unwrap();
22002        let node = &rt.content[0].content.as_ref().unwrap()[0];
22003        let mark_types: Vec<&str> = node
22004            .marks
22005            .as_ref()
22006            .unwrap()
22007            .iter()
22008            .map(|m| m.mark_type.as_str())
22009            .collect();
22010        assert_eq!(mark_types, vec!["underline", "textColor", "link"]);
22011    }
22012
22013    #[test]
22014    fn issue_554_link_underline_textcolor_link_outer() {
22015        // Mark order [link, underline, textColor] — link outermost, wraps a
22016        // bracketed-span that wraps the span. Exercises the link-wraps-
22017        // bracketed-wraps-span branch.
22018        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22019          {"type":"text","text":"linked","marks":[
22020            {"type":"link","attrs":{"href":"https://example.com"}},
22021            {"type":"underline"},
22022            {"type":"textColor","attrs":{"color":"#008000"}}
22023          ]}
22024        ]}]}"##;
22025        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22026        let md = adf_to_markdown(&doc).unwrap();
22027        assert!(
22028            md.starts_with('[') && md.contains("](https://example.com)"),
22029            "link should be outermost, got: {md}"
22030        );
22031        let rt = markdown_to_adf(&md).unwrap();
22032        let node = &rt.content[0].content.as_ref().unwrap()[0];
22033        let mark_types: Vec<&str> = node
22034            .marks
22035            .as_ref()
22036            .unwrap()
22037            .iter()
22038            .map(|m| m.mark_type.as_str())
22039            .collect();
22040        assert_eq!(mark_types, vec!["link", "underline", "textColor"]);
22041    }
22042
22043    #[test]
22044    fn issue_554_trailing_underscore_then_leading_underscore_round_trip() {
22045        // Two adjacent text nodes where the first ends with `_` and the
22046        // second starts with `_` — without escaping, the JFM parser sees
22047        // an `_..._` pair spanning the boundary.
22048        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22049          {"type":"text","text":"end_"},
22050          {"type":"text","text":"_start"}
22051        ]}]}"#;
22052        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22053        let md = adf_to_markdown(&doc).unwrap();
22054        let rt = markdown_to_adf(&md).unwrap();
22055        // Reassemble all text in the paragraph.
22056        let combined: String = rt.content[0]
22057            .content
22058            .as_ref()
22059            .unwrap()
22060            .iter()
22061            .filter_map(|n| n.text.as_deref())
22062            .collect();
22063        assert_eq!(combined, "end__start");
22064        // No node should have an em mark.
22065        for n in rt.content[0].content.as_ref().unwrap() {
22066            if let Some(ms) = n.marks.as_deref() {
22067                assert!(!ms.iter().any(|m| m.mark_type == "em"));
22068            }
22069        }
22070    }
22071}