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::{bail, 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/// Maximum nesting depth for markdown → ADF conversion.
21///
22/// The parser recurses once per nesting level (blockquotes, lists, container
23/// directives, directive-table cells, inline marks), so unbounded input would
24/// overflow the stack — an uncatchable abort that would kill the long-lived
25/// MCP server (issue #1130).  The ADF → markdown direction is already bounded
26/// at 128 by serde_json's recursion limit; this cap is lower because the
27/// nested-list frame chain is heavy enough in debug builds that at-cap
28/// parsing must still fit within a 2 MiB thread stack.  Realistic documents
29/// nest well under 20 levels.
30const MAX_NESTING_DEPTH: usize = 64;
31
32/// Converts a markdown string to an ADF document.
33pub fn markdown_to_adf(markdown: &str) -> Result<AdfDocument> {
34    debug!(
35        "markdown_to_adf: input {} bytes, {} lines",
36        markdown.len(),
37        markdown.lines().count()
38    );
39    let mut doc = AdfDocument::new();
40    let mut parser = MarkdownParser::new(markdown);
41    doc.content = parser.parse_blocks()?;
42    debug!(
43        "markdown_to_adf: produced {} top-level ADF nodes",
44        doc.content.len()
45    );
46    Ok(doc)
47}
48
49/// Line-oriented state machine for parsing markdown into ADF block nodes.
50struct MarkdownParser<'a> {
51    lines: Vec<&'a str>,
52    pos: usize,
53    /// Nesting depth of this parser: 0 at the document root, +1 for each
54    /// nested parser spawned for container content (blockquote, list item,
55    /// panel, layout column, table cell, …).  Checked against
56    /// [`MAX_NESTING_DEPTH`] in [`Self::parse_blocks`].
57    depth: usize,
58}
59
60impl<'a> MarkdownParser<'a> {
61    fn new(input: &'a str) -> Self {
62        Self::with_depth(input, 0)
63    }
64
65    /// Creates a nested parser for container content at the given depth.
66    fn with_depth(input: &'a str, depth: usize) -> Self {
67        Self {
68            lines: input.lines().collect(),
69            pos: 0,
70            depth,
71        }
72    }
73
74    fn at_end(&self) -> bool {
75        self.pos >= self.lines.len()
76    }
77
78    fn current_line(&self) -> &'a str {
79        self.lines[self.pos]
80    }
81
82    fn advance(&mut self) {
83        self.pos += 1;
84    }
85
86    /// Collects indented continuation lines produced by hardBreaks (issue #402).
87    ///
88    /// When `full_text` ends with a hardBreak marker (trailing backslash or
89    /// two trailing spaces), the next 2-space-indented line is appended as a
90    /// continuation of the same paragraph.  The joined text is later fed to
91    /// `parse_inline`, which converts the `\\\n` or `  \n` sequences back
92    /// into `hardBreak` nodes.
93    fn collect_hardbreak_continuations(&mut self, full_text: &mut String) {
94        while has_trailing_hard_break(full_text) && !self.at_end() {
95            if !self.try_append_hardbreak_continuation(full_text) {
96                break;
97            }
98        }
99    }
100
101    /// If the current line is a valid hardBreak continuation (2-space indented
102    /// and not a block-level sibling marker), append it to `full_text` and
103    /// advance the parser.  Returns `true` on success, `false` otherwise.
104    ///
105    /// Split out from `collect_hardbreak_continuations` so the body appears
106    /// as its own function in coverage reports (issue #552 PR coverage gap).
107    fn try_append_hardbreak_continuation(&mut self, full_text: &mut String) -> bool {
108        // Skip indented block-level siblings — mediaSingle (`![` — issue
109        // #490), fenced code blocks (```` ``` ```` — issue #552), and
110        // container directives (`:::`).  They must stay available for their
111        // dedicated block handlers instead of being merged into paragraph
112        // text.
113        match self
114            .current_line()
115            .strip_prefix("  ")
116            .filter(|s| !is_block_level_continuation_marker(s.trim_start()))
117        {
118            Some(stripped) => {
119                full_text.push('\n');
120                full_text.push_str(stripped);
121                self.advance();
122                true
123            }
124            None => false,
125        }
126    }
127
128    fn parse_blocks(&mut self) -> Result<Vec<AdfNode>> {
129        if self.depth > MAX_NESTING_DEPTH {
130            bail!(
131                "Markdown nesting exceeds the maximum depth of {MAX_NESTING_DEPTH} \
132                 (nested blockquotes, lists, panels, expands, layouts, or table \
133                 cells); flatten the document structure"
134            );
135        }
136
137        let mut blocks = Vec::new();
138
139        while !self.at_end() {
140            let line = self.current_line();
141
142            if line.trim().is_empty() {
143                self.advance();
144                continue;
145            }
146
147            let mut node = if let Some(node) = self.try_heading() {
148                node
149            } else if let Some(node) = self.try_horizontal_rule() {
150                node
151            } else if let Some(node) = self.try_container_directive()? {
152                node
153            } else if let Some(node) = self.try_code_block()? {
154                node
155            } else if let Some(node) = self.try_table()? {
156                node
157            } else if let Some(node) = self.try_blockquote()? {
158                node
159            } else if let Some(node) = self.try_list()? {
160                node
161            } else if let Some(node) = self.try_leaf_directive() {
162                node
163            } else if let Some(node) = self.try_image() {
164                node
165            } else {
166                self.parse_paragraph()?
167            };
168
169            // Check for trailing block-level {attrs} (align, indent, breakout)
170            self.try_apply_block_attrs(&mut node);
171            blocks.push(node);
172        }
173
174        Ok(blocks)
175    }
176
177    fn try_heading(&mut self) -> Option<AdfNode> {
178        let line = self.current_line();
179        let trimmed = line.trim_start();
180
181        if !trimmed.starts_with('#') {
182            return None;
183        }
184
185        let level = trimmed.chars().take_while(|&c| c == '#').count();
186        if !(1..=6).contains(&level) || !trimmed[level..].starts_with(' ') {
187            return None;
188        }
189
190        let mut full_text = trimmed[level + 1..].to_string();
191        self.advance();
192        // Collect indented continuation lines produced by hardBreaks (issue #433).
193        self.collect_hardbreak_continuations(&mut full_text);
194        let mut inline_nodes = parse_inline(&full_text);
195        // ADF's `heading` content model forbids the `code` mark, but
196        // JFM/CommonMark parses inline-code spans inside `#`-headings. Strip
197        // them here (keeping the text as plain) so we don't build a document
198        // the validator only rejects at write time (issue #1005).
199        if strip_heading_code_marks(&mut inline_nodes) > 0 {
200            warn!(
201                "Stripped inline `code` from heading (ADF forbids code marks on \
202                 headings; kept the text as plain): {}",
203                full_text.trim()
204            );
205        }
206
207        #[allow(clippy::cast_possible_truncation)]
208        Some(AdfNode::heading(level as u8, inline_nodes))
209    }
210
211    fn try_horizontal_rule(&mut self) -> Option<AdfNode> {
212        let line = self.current_line().trim();
213        let is_rule = (line.starts_with("---") && line.chars().all(|c| c == '-'))
214            || (line.starts_with("***") && line.chars().all(|c| c == '*'))
215            || (line.starts_with("___") && line.chars().all(|c| c == '_'));
216
217        if is_rule && line.len() >= 3 {
218            self.advance();
219            Some(AdfNode::rule())
220        } else {
221            None
222        }
223    }
224
225    fn try_code_block(&mut self) -> Result<Option<AdfNode>> {
226        let line = self.current_line();
227        if !is_code_fence_opener(line) {
228            return Ok(None);
229        }
230
231        let language = line[3..].trim();
232        let language = if language == "\"\"" {
233            // Explicit empty language attr encoded as ```""
234            Some(String::new())
235        } else if language.is_empty() {
236            None
237        } else {
238            Some(language.to_string())
239        };
240
241        self.advance();
242        let mut code_lines = Vec::new();
243
244        while !self.at_end() {
245            let line = self.current_line();
246            if line.starts_with("```") {
247                self.advance();
248                break;
249            }
250            code_lines.push(line);
251            self.advance();
252        }
253
254        let code_text = code_lines.join("\n");
255
256        // If the language is "adf-unsupported", deserialize the JSON back to an AdfNode
257        if language.as_deref() == Some("adf-unsupported") {
258            if let Ok(node) = serde_json::from_str::<AdfNode>(&code_text) {
259                return Ok(Some(node));
260            }
261        }
262
263        Ok(Some(AdfNode::code_block(language.as_deref(), &code_text)))
264    }
265
266    fn try_blockquote(&mut self) -> Result<Option<AdfNode>> {
267        let line = self.current_line();
268        if !line.starts_with('>') {
269            return Ok(None);
270        }
271
272        let mut quote_lines = Vec::new();
273        while !self.at_end() {
274            let line = self.current_line();
275            if let Some(rest) = line.strip_prefix("> ") {
276                quote_lines.push(rest);
277                self.advance();
278            } else if let Some(rest) = line.strip_prefix('>') {
279                quote_lines.push(rest);
280                self.advance();
281            } else {
282                break;
283            }
284        }
285
286        let quote_text = quote_lines.join("\n");
287        let mut inner_parser = MarkdownParser::with_depth(&quote_text, self.depth + 1);
288        let inner_blocks = inner_parser.parse_blocks()?;
289
290        Ok(Some(AdfNode::blockquote(inner_blocks)))
291    }
292
293    fn try_list(&mut self) -> Result<Option<AdfNode>> {
294        let line = self.current_line();
295        let trimmed = line.trim_start();
296
297        let is_bullet =
298            trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ");
299        let ordered_match = parse_ordered_list_marker(trimmed);
300
301        if !is_bullet && ordered_match.is_none() {
302            return Ok(None);
303        }
304
305        if is_bullet {
306            self.parse_bullet_list()
307        } else {
308            let start = ordered_match.map_or(1, |(n, _)| n);
309            self.parse_ordered_list(start)
310        }
311    }
312
313    fn parse_bullet_list(&mut self) -> Result<Option<AdfNode>> {
314        let mut items = Vec::new();
315        let mut is_task_list = false;
316
317        while !self.at_end() {
318            let line = self.current_line();
319            let trimmed = line.trim_start();
320
321            if !(trimmed.starts_with("- ")
322                || trimmed.starts_with("* ")
323                || trimmed.starts_with("+ "))
324            {
325                break;
326            }
327
328            let after_marker = trimmed[2..].trim_start();
329
330            // Detect task list items: - [ ] or - [x]
331            if let Some((state, text)) = try_parse_task_marker(after_marker) {
332                is_task_list = true;
333                self.advance();
334                // Collect hardBreak continuation lines so that a trailing
335                // {localId=…} on the last continuation line is found by
336                // extract_trailing_local_id (issue #507).
337                let mut full_text = text.to_string();
338                self.collect_hardbreak_continuations(&mut full_text);
339                let (item_text, local_id, para_local_id) = extract_trailing_local_id(&full_text);
340                let inline_nodes = parse_inline(item_text);
341                // If a paraLocalId marker is present the original ADF had a
342                // paragraph wrapper around the inline content — restore it
343                // so the round-trip is lossless (issue #478).
344                let content = if let Some(ref plid) = para_local_id {
345                    let mut para = AdfNode::paragraph(inline_nodes);
346                    if plid != "_" {
347                        para.attrs = Some(serde_json::json!({"localId": plid}));
348                    }
349                    vec![para]
350                } else {
351                    inline_nodes
352                };
353                let mut task = AdfNode::task_item(state, content);
354                // Override the placeholder localId if one was parsed
355                if let Some(id) = local_id {
356                    if let Some(ref mut attrs) = task.attrs {
357                        attrs["localId"] = serde_json::Value::String(id);
358                    }
359                }
360                // Collect indented sub-content (e.g. nested task lists
361                // from malformed ADF where taskItem contains taskItem
362                // children directly — issue #489).
363                let mut sub_lines: Vec<String> = Vec::new();
364                while !self.at_end() && self.current_line().starts_with("  ") {
365                    let stripped = &self.current_line()[2..];
366                    sub_lines.push(stripped.to_string());
367                    self.advance();
368                }
369                if !sub_lines.is_empty() {
370                    let sub_text = sub_lines.join("\n");
371                    let mut nested =
372                        MarkdownParser::with_depth(&sub_text, self.depth + 1).parse_blocks()?;
373                    // When the task item has no inline text and its
374                    // sub-content is a single taskList, this is a
375                    // container taskItem from malformed ADF (issue #489).
376                    // Unwrap the taskList so the taskItem children sit
377                    // directly in the container, and drop the spurious
378                    // `state` attr that was injected by the checkbox
379                    // marker.
380                    let is_empty = task.content.as_ref().map_or(true, Vec::is_empty);
381                    if is_empty && nested.len() == 1 && nested[0].node_type == "taskList" {
382                        if let Some(task_items) = nested.remove(0).content {
383                            task.content = Some(task_items);
384                        }
385                        if let Some(ref mut attrs) = task.attrs {
386                            if let Some(obj) = attrs.as_object_mut() {
387                                obj.remove("state");
388                            }
389                        }
390                        items.push(task);
391                    } else {
392                        // Separate nested taskList nodes from other block
393                        // content.  Nested taskLists become sibling children
394                        // of the outer taskList rather than children of this
395                        // taskItem, matching ADF's representation of indented
396                        // sub-lists (issue #506).
397                        let mut sibling_task_lists = Vec::new();
398                        let mut child_nodes = Vec::new();
399                        for n in nested {
400                            if n.node_type == "taskList" {
401                                sibling_task_lists.push(n);
402                            } else {
403                                child_nodes.push(n);
404                            }
405                        }
406                        if !child_nodes.is_empty() {
407                            match task.content {
408                                Some(ref mut content) => content.append(&mut child_nodes),
409                                None => task.content = Some(child_nodes),
410                            }
411                        }
412                        items.push(task);
413                        items.append(&mut sibling_task_lists);
414                    }
415                } else {
416                    items.push(task);
417                }
418            } else {
419                let first_line = &trimmed[2..];
420                self.advance();
421                let mut full_text = first_line.to_string();
422                self.collect_hardbreak_continuations(&mut full_text);
423                let (item_text, local_id, para_local_id) = extract_trailing_local_id(&full_text);
424                // Collect indented sub-content lines (2-space prefix).
425                // This captures both nested lists and continuation
426                // paragraphs that belong to the same list item.
427                let mut sub_lines: Vec<String> = Vec::new();
428                while !self.at_end() {
429                    let next = self.current_line();
430                    if let Some(stripped) = next.strip_prefix("  ") {
431                        sub_lines.push(stripped.to_string());
432                        self.advance();
433                        continue;
434                    }
435                    break;
436                }
437                let item_content = parse_list_item_first_line(
438                    item_text,
439                    sub_lines,
440                    local_id,
441                    para_local_id,
442                    self.depth + 1,
443                )?;
444                items.push(item_content);
445            }
446        }
447
448        if items.is_empty() {
449            Ok(None)
450        } else if is_task_list {
451            Ok(Some(AdfNode::task_list(items)))
452        } else {
453            Ok(Some(AdfNode::bullet_list(items)))
454        }
455    }
456
457    fn parse_ordered_list(&mut self, start: u32) -> Result<Option<AdfNode>> {
458        let mut items = Vec::new();
459
460        while !self.at_end() {
461            let line = self.current_line();
462            let trimmed = line.trim_start();
463
464            if let Some((_, rest)) = parse_ordered_list_marker(trimmed) {
465                let first_line = rest.trim_start_matches(|c: char| c.is_ascii_whitespace());
466                self.advance();
467                let mut full_text = first_line.to_string();
468                self.collect_hardbreak_continuations(&mut full_text);
469                let (item_text, local_id, para_local_id) = extract_trailing_local_id(&full_text);
470                // Collect indented sub-content lines (2-space prefix).
471                let mut sub_lines: Vec<String> = Vec::new();
472                while !self.at_end() {
473                    let next = self.current_line();
474                    if let Some(stripped) = next.strip_prefix("  ") {
475                        sub_lines.push(stripped.to_string());
476                        self.advance();
477                        continue;
478                    }
479                    break;
480                }
481                let item_content = parse_list_item_first_line(
482                    item_text,
483                    sub_lines,
484                    local_id,
485                    para_local_id,
486                    self.depth + 1,
487                )?;
488                items.push(item_content);
489            } else {
490                break;
491            }
492        }
493
494        if items.is_empty() {
495            Ok(None)
496        } else {
497            let order = if start == 1 { None } else { Some(start) };
498            Ok(Some(AdfNode::ordered_list(items, order)))
499        }
500    }
501
502    fn try_apply_block_attrs(&mut self, node: &mut AdfNode) {
503        if self.at_end() {
504            return;
505        }
506        let line = self.current_line().trim();
507        if !line.starts_with('{') {
508            return;
509        }
510        let Some((_, attrs)) = parse_attrs(line, 0) else {
511            return;
512        };
513
514        let mut marks = Vec::new();
515        if let Some(align) = attrs.get("align") {
516            marks.push(AdfMark::alignment(align));
517        }
518        if let Some(indent) = attrs.get("indent") {
519            if let Ok(level) = indent.parse::<u32>() {
520                marks.push(AdfMark::indentation(level));
521            }
522        }
523        if let Some(mode) = attrs.get("breakout") {
524            let width = attrs
525                .get("breakoutWidth")
526                .and_then(|w| w.parse::<u32>().ok());
527            marks.push(AdfMark::breakout(mode, width));
528        }
529
530        // Parse localId from block attrs
531        let local_id = attrs.get("localId").map(str::to_string);
532
533        // Parse explicit order for orderedList nodes (issue #547).
534        let order = if node.node_type == "orderedList" {
535            attrs.get("order").and_then(|v| v.parse::<u32>().ok())
536        } else {
537            None
538        };
539
540        let has_attrs = !marks.is_empty() || local_id.is_some() || order.is_some();
541        if has_attrs {
542            if !marks.is_empty() {
543                let existing = node.marks.get_or_insert_with(Vec::new);
544                existing.extend(marks);
545            }
546            if let Some(id) = local_id {
547                let node_attrs = node.attrs.get_or_insert_with(|| serde_json::json!({}));
548                node_attrs["localId"] = serde_json::Value::String(id);
549            }
550            if let Some(n) = order {
551                let node_attrs = node.attrs.get_or_insert_with(|| serde_json::json!({}));
552                node_attrs["order"] = serde_json::json!(n);
553            }
554            self.advance(); // consume the attrs line
555        }
556    }
557
558    fn try_container_directive(&mut self) -> Result<Option<AdfNode>> {
559        let line = self.current_line();
560        let Some((d, colon_count)) = try_parse_container_open(line) else {
561            return Ok(None);
562        };
563        self.advance(); // past opening fence
564
565        // Collect inner lines until the matching close fence, tracking nesting
566        let mut inner_lines = Vec::new();
567        let mut depth: usize = 0;
568        while !self.at_end() {
569            let current = self.current_line();
570            if try_parse_container_open(current).is_some() {
571                depth += 1;
572            } else if depth == 0 && is_container_close(current, colon_count) {
573                self.advance(); // past closing fence
574                break;
575            } else if depth > 0 && is_container_close(current, 3) {
576                depth -= 1;
577            }
578            inner_lines.push(current.to_string());
579            self.advance();
580        }
581
582        let inner_text = inner_lines.join("\n");
583
584        let node = match d.name.as_str() {
585            "panel" => {
586                let panel_type = d
587                    .attrs
588                    .as_ref()
589                    .and_then(|a| a.get("type"))
590                    .unwrap_or("info");
591                let inner_blocks =
592                    MarkdownParser::with_depth(&inner_text, self.depth + 1).parse_blocks()?;
593                let mut node = AdfNode::panel(panel_type, inner_blocks);
594                // Pass through custom panel attrs (icon, color)
595                if let Some(ref attrs) = d.attrs {
596                    if let Some(ref mut node_attrs) = node.attrs {
597                        if let Some(icon) = attrs.get("icon") {
598                            node_attrs["panelIcon"] = serde_json::Value::String(icon.to_string());
599                        }
600                        if let Some(color) = attrs.get("color") {
601                            node_attrs["panelColor"] = serde_json::Value::String(color.to_string());
602                        }
603                    }
604                }
605                node
606            }
607            "expand" => {
608                let title = d.attrs.as_ref().and_then(|a| a.get("title"));
609                let inner_blocks =
610                    MarkdownParser::with_depth(&inner_text, self.depth + 1).parse_blocks()?;
611                let mut node = AdfNode::expand(title, inner_blocks);
612                pass_through_expand_params(&d.attrs, &mut node);
613                node
614            }
615            "nested-expand" => {
616                let title = d.attrs.as_ref().and_then(|a| a.get("title"));
617                let inner_blocks =
618                    MarkdownParser::with_depth(&inner_text, self.depth + 1).parse_blocks()?;
619                let mut node = AdfNode::nested_expand(title, inner_blocks);
620                pass_through_expand_params(&d.attrs, &mut node);
621                node
622            }
623            "layout" => {
624                // Parse inner content looking for :::column sub-containers
625                let columns = self.parse_layout_columns(&inner_text)?;
626                AdfNode::layout_section(columns)
627            }
628            "decisions" => {
629                let items = parse_decision_items(&inner_text);
630                AdfNode::decision_list(items)
631            }
632            "table" => {
633                let rows = self.parse_directive_table_rows(&inner_text)?;
634                let mut table_attrs = serde_json::json!({});
635                if let Some(ref attrs) = d.attrs {
636                    if let Some(layout) = attrs.get("layout") {
637                        table_attrs["layout"] = serde_json::Value::String(layout.to_string());
638                    }
639                    if attrs.has_flag("numbered") {
640                        table_attrs["isNumberColumnEnabled"] = serde_json::json!(true);
641                    } else if attrs.get("numbered") == Some("false") {
642                        table_attrs["isNumberColumnEnabled"] = serde_json::json!(false);
643                    }
644                    if let Some(tw) = attrs.get("width") {
645                        if let Some(w) = parse_numeric_attr(tw) {
646                            table_attrs["width"] = w;
647                        }
648                    }
649                    if let Some(local_id) = attrs.get("localId") {
650                        table_attrs["localId"] = serde_json::Value::String(local_id.to_string());
651                    }
652                }
653                if table_attrs == serde_json::json!({}) {
654                    AdfNode::table(rows)
655                } else {
656                    AdfNode::table_with_attrs(rows, table_attrs)
657                }
658            }
659            "extension" => {
660                let ext_type = d.attrs.as_ref().and_then(|a| a.get("type")).unwrap_or("");
661                let ext_key = d.attrs.as_ref().and_then(|a| a.get("key")).unwrap_or("");
662                let inner_blocks =
663                    MarkdownParser::with_depth(&inner_text, self.depth + 1).parse_blocks()?;
664                let mut node = AdfNode::bodied_extension(ext_type, ext_key, inner_blocks);
665                if let (Some(ref dir_attrs), Some(ref mut node_attrs)) = (&d.attrs, &mut node.attrs)
666                {
667                    if let Some(layout) = dir_attrs.get("layout") {
668                        node_attrs["layout"] = serde_json::Value::String(layout.to_string());
669                    }
670                    if let Some(local_id) = dir_attrs.get("localId") {
671                        node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
672                    }
673                    if let Some(params_str) = dir_attrs.get("params") {
674                        if let Ok(params_val) =
675                            serde_json::from_str::<serde_json::Value>(params_str)
676                        {
677                            node_attrs["parameters"] = params_val;
678                        }
679                    }
680                }
681                node
682            }
683            _ => return Ok(None),
684        };
685
686        Ok(Some(node))
687    }
688
689    fn parse_layout_columns(&self, inner_text: &str) -> Result<Vec<AdfNode>> {
690        let mut columns = Vec::new();
691        let mut current_column_lines: Vec<String> = Vec::new();
692        let mut current_width: serde_json::Value = serde_json::json!(50);
693        let mut current_dir_attrs: Option<crate::atlassian::attrs::Attrs> = None;
694        let mut in_column = false;
695        let mut depth: usize = 0;
696
697        let lines: Vec<&str> = inner_text.lines().collect();
698        let mut i = 0;
699
700        while i < lines.len() {
701            let line = lines[i];
702            if let Some((col_d, _)) = try_parse_container_open(line) {
703                if col_d.name == "column" && depth == 0 {
704                    // Flush previous column
705                    if in_column && !current_column_lines.is_empty() {
706                        let col_text = current_column_lines.join("\n");
707                        let blocks =
708                            MarkdownParser::with_depth(&col_text, self.depth + 1).parse_blocks()?;
709                        let mut col = AdfNode::layout_column(current_width.clone(), blocks);
710                        pass_through_local_id(&current_dir_attrs, &mut col);
711                        columns.push(col);
712                        current_column_lines.clear();
713                    }
714                    current_width = col_d
715                        .attrs
716                        .as_ref()
717                        .and_then(|a| a.get("width"))
718                        .and_then(parse_numeric_attr)
719                        .unwrap_or_else(|| serde_json::json!(50));
720                    current_dir_attrs = col_d.attrs;
721                    in_column = true;
722                    i += 1;
723                    continue;
724                }
725                if in_column {
726                    depth += 1;
727                }
728            }
729            if in_column && is_container_close(line, 3) {
730                if depth > 0 {
731                    depth -= 1;
732                    current_column_lines.push(line.to_string());
733                    i += 1;
734                    continue;
735                }
736                // End of column
737                let col_text = current_column_lines.join("\n");
738                let blocks =
739                    MarkdownParser::with_depth(&col_text, self.depth + 1).parse_blocks()?;
740                let mut col = AdfNode::layout_column(current_width.clone(), blocks);
741                pass_through_local_id(&current_dir_attrs, &mut col);
742                columns.push(col);
743                current_column_lines.clear();
744                current_dir_attrs = None;
745                in_column = false;
746                i += 1;
747                continue;
748            }
749            if in_column {
750                current_column_lines.push(line.to_string());
751            }
752            i += 1;
753        }
754
755        // Flush last column if no closing fence
756        if in_column && !current_column_lines.is_empty() {
757            let col_text = current_column_lines.join("\n");
758            let blocks = MarkdownParser::with_depth(&col_text, self.depth + 1).parse_blocks()?;
759            let mut col = AdfNode::layout_column(current_width, blocks);
760            pass_through_local_id(&current_dir_attrs, &mut col);
761            columns.push(col);
762        }
763
764        Ok(columns)
765    }
766
767    /// Parses `:::tr` / `:::th` / `:::td` sub-containers inside a `:::table` directive.
768    fn parse_directive_table_rows(&self, inner_text: &str) -> Result<Vec<AdfNode>> {
769        debug!(
770            "parse_directive_table_rows: {} lines of inner text",
771            inner_text.lines().count()
772        );
773        let mut rows = Vec::new();
774        let lines: Vec<&str> = inner_text.lines().collect();
775        let mut i = 0;
776
777        while i < lines.len() {
778            let line = lines[i];
779            if let Some((d, _)) = try_parse_container_open(line) {
780                if d.name == "tr" {
781                    let tr_attrs = d.attrs.clone();
782                    i += 1;
783                    let (mut row, next_i) = self.parse_directive_table_row(&lines, i)?;
784                    // Pass through localId from :::tr{localId=...}
785                    if let Some(ref attrs) = tr_attrs {
786                        if let Some(local_id) = attrs.get("localId") {
787                            let row_attrs = row.attrs.get_or_insert_with(|| serde_json::json!({}));
788                            row_attrs["localId"] = serde_json::Value::String(local_id.to_string());
789                        }
790                    }
791                    rows.push(row);
792                    i = next_i;
793                    continue;
794                }
795                if d.name == "caption" {
796                    let dir_attrs = d.attrs.clone();
797                    i += 1;
798                    let mut caption_lines = Vec::new();
799                    while i < lines.len() {
800                        if is_container_close(lines[i], 3) {
801                            i += 1;
802                            break;
803                        }
804                        caption_lines.push(lines[i]);
805                        i += 1;
806                    }
807                    let caption_text = caption_lines.join("\n");
808                    let inline_nodes = parse_inline(&caption_text);
809                    let mut caption = AdfNode::caption(inline_nodes);
810                    pass_through_local_id(&dir_attrs, &mut caption);
811                    rows.push(caption);
812                    continue;
813                }
814            }
815            i += 1;
816        }
817
818        Ok(rows)
819    }
820
821    /// Parses cells within a `:::tr` container until its closing fence.
822    fn parse_directive_table_row(&self, lines: &[&str], start: usize) -> Result<(AdfNode, usize)> {
823        let mut cells = Vec::new();
824        let mut i = start;
825        let mut depth: usize = 0;
826
827        while i < lines.len() {
828            let line = lines[i];
829            if is_container_close(line, 3) {
830                if depth == 0 {
831                    // End of :::tr
832                    i += 1;
833                    break;
834                }
835                depth -= 1;
836                i += 1;
837                continue;
838            }
839            if let Some((d, _)) = try_parse_container_open(line) {
840                if depth == 0 && (d.name == "th" || d.name == "td") {
841                    let is_header = d.name == "th";
842                    let cell_attrs = d.attrs.clone();
843                    i += 1;
844                    let (cell, next_i) =
845                        self.parse_directive_table_cell(lines, i, is_header, cell_attrs)?;
846                    cells.push(cell);
847                    i = next_i;
848                    continue;
849                }
850                depth += 1;
851            }
852            i += 1;
853        }
854
855        if cells.is_empty() {
856            let context = lines[start.saturating_sub(1)..lines.len().min(start + 3)].to_vec();
857            warn!(
858                "Directive table row at line {start} has no cells — \
859                 Confluence requires at least one. Nearby lines: {context:?}"
860            );
861        }
862        debug!("Parsed directive table row: {} cells", cells.len());
863
864        Ok((AdfNode::table_row(cells), i))
865    }
866
867    /// Parses the content of a `:::th` or `:::td` cell until its closing fence.
868    fn parse_directive_table_cell(
869        &self,
870        lines: &[&str],
871        start: usize,
872        is_header: bool,
873        cell_attrs: Option<crate::atlassian::attrs::Attrs>,
874    ) -> Result<(AdfNode, usize)> {
875        let mut cell_lines = Vec::new();
876        let mut i = start;
877        let mut depth: usize = 0;
878
879        while i < lines.len() {
880            let line = lines[i];
881            if try_parse_container_open(line).is_some() {
882                depth += 1;
883            } else if is_container_close(line, 3) {
884                if depth == 0 {
885                    i += 1;
886                    break;
887                }
888                depth -= 1;
889            }
890            cell_lines.push(line.to_string());
891            i += 1;
892        }
893
894        let cell_text = cell_lines.join("\n");
895        let blocks = MarkdownParser::with_depth(&cell_text, self.depth + 1).parse_blocks()?;
896
897        let adf_attrs = cell_attrs.as_ref().map(build_cell_attrs);
898        let cell_marks = cell_attrs
899            .as_ref()
900            .map(build_border_marks)
901            .unwrap_or_default();
902
903        let cell = if cell_marks.is_empty() {
904            if is_header {
905                if let Some(attrs) = adf_attrs {
906                    AdfNode::table_header_with_attrs(blocks, attrs)
907                } else {
908                    AdfNode::table_header(blocks)
909                }
910            } else if let Some(attrs) = adf_attrs {
911                AdfNode::table_cell_with_attrs(blocks, attrs)
912            } else {
913                AdfNode::table_cell(blocks)
914            }
915        } else if is_header {
916            AdfNode::table_header_with_attrs_and_marks(blocks, adf_attrs, cell_marks)
917        } else {
918            AdfNode::table_cell_with_attrs_and_marks(blocks, adf_attrs, cell_marks)
919        };
920
921        Ok((cell, i))
922    }
923
924    fn try_leaf_directive(&mut self) -> Option<AdfNode> {
925        let line = self.current_line();
926        let d = try_parse_leaf_directive(line)?;
927
928        let node = match d.name.as_str() {
929            "card" => {
930                let content = d.content.as_deref().unwrap_or("");
931                // Prefer the `url` attribute when present; fall back to the
932                // bracketed content.  The attribute form is used when the URL
933                // contains characters that would otherwise break
934                // `::card[URL]` parsing.
935                let url = match d.attrs.as_ref().and_then(|a| a.get("url")) {
936                    Some(u) => u,
937                    None => content,
938                };
939                let mut node = AdfNode::block_card(url);
940                // Pass through layout/width attrs
941                if let Some(ref attrs) = d.attrs {
942                    if let Some(ref mut node_attrs) = node.attrs {
943                        if let Some(layout) = attrs.get("layout") {
944                            node_attrs["layout"] = serde_json::Value::String(layout.to_string());
945                        }
946                        if let Some(width) = attrs.get("width") {
947                            if let Ok(w) = width.parse::<u64>() {
948                                node_attrs["width"] = serde_json::json!(w);
949                            }
950                        }
951                    }
952                }
953                node
954            }
955            "embed" => {
956                let url = d.content.as_deref().unwrap_or("");
957                let layout = d.attrs.as_ref().and_then(|a| a.get("layout"));
958                let original_height = d
959                    .attrs
960                    .as_ref()
961                    .and_then(|a| a.get("originalHeight"))
962                    .and_then(|v| v.parse::<f64>().ok());
963                let width = d
964                    .attrs
965                    .as_ref()
966                    .and_then(|a| a.get("width"))
967                    .and_then(|w| w.parse::<f64>().ok());
968                AdfNode::embed_card(url, layout, original_height, width)
969            }
970            "extension" => {
971                let ext_type = d.attrs.as_ref().and_then(|a| a.get("type")).unwrap_or("");
972                let ext_key = d.attrs.as_ref().and_then(|a| a.get("key")).unwrap_or("");
973                let params = d
974                    .attrs
975                    .as_ref()
976                    .and_then(|a| a.get("params"))
977                    .and_then(|p| serde_json::from_str(p).ok());
978                let mut node = AdfNode::extension(ext_type, ext_key, params);
979                if let (Some(ref dir_attrs), Some(ref mut node_attrs)) = (&d.attrs, &mut node.attrs)
980                {
981                    if let Some(layout) = dir_attrs.get("layout") {
982                        node_attrs["layout"] = serde_json::Value::String(layout.to_string());
983                    }
984                    if let Some(local_id) = dir_attrs.get("localId") {
985                        node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
986                    }
987                }
988                node
989            }
990            "paragraph" => {
991                let mut node = if let Some(ref text) = d.content {
992                    AdfNode::paragraph(parse_inline(text))
993                } else {
994                    AdfNode::paragraph(vec![])
995                };
996                pass_through_local_id(&d.attrs, &mut node);
997                node
998            }
999            _ => return None,
1000        };
1001
1002        self.advance();
1003        Some(node)
1004    }
1005
1006    fn try_image(&mut self) -> Option<AdfNode> {
1007        let line = self.current_line().trim();
1008        let mut node = try_parse_media_single_from_line(line)?;
1009        self.advance();
1010
1011        // Check for a trailing :::caption directive
1012        if !self.at_end() {
1013            if let Some((d, _)) = try_parse_container_open(self.current_line()) {
1014                if d.name == "caption" {
1015                    let dir_attrs = d.attrs;
1016                    self.advance(); // past :::caption
1017                    let mut caption_lines = Vec::new();
1018                    while !self.at_end() {
1019                        if is_container_close(self.current_line(), 3) {
1020                            self.advance(); // past :::
1021                            break;
1022                        }
1023                        caption_lines.push(self.current_line());
1024                        self.advance();
1025                    }
1026                    let caption_text = caption_lines.join("\n");
1027                    let inline_nodes = parse_inline(&caption_text);
1028                    let mut caption = AdfNode::caption(inline_nodes);
1029                    pass_through_local_id(&dir_attrs, &mut caption);
1030                    if let Some(ref mut content) = node.content {
1031                        content.push(caption);
1032                    }
1033                }
1034            }
1035        }
1036
1037        Some(node)
1038    }
1039
1040    fn try_table(&mut self) -> Result<Option<AdfNode>> {
1041        let line = self.current_line();
1042        if !line.contains('|') || !line.trim_start().starts_with('|') {
1043            return Ok(None);
1044        }
1045
1046        // Peek ahead to check for a separator row (indicates a table)
1047        if self.pos + 1 >= self.lines.len() {
1048            return Ok(None);
1049        }
1050        let next_line = self.lines[self.pos + 1];
1051        if !is_table_separator(next_line) {
1052            return Ok(None);
1053        }
1054
1055        // Parse header row
1056        let header_cells = parse_table_row(line);
1057        self.advance(); // skip header
1058
1059        // Parse separator row for column alignment
1060        let sep_line = self.current_line();
1061        let alignments = parse_table_alignments(sep_line);
1062        self.advance(); // skip separator
1063
1064        let mut rows = Vec::new();
1065
1066        // Header row — parse cell attrs and apply column alignment
1067        let header_adf_cells: Vec<AdfNode> = header_cells
1068            .iter()
1069            .enumerate()
1070            .map(|(col_idx, cell)| {
1071                let (cell_text, cell_attrs) = extract_cell_attrs(cell);
1072                let mut para = AdfNode::paragraph(parse_inline(&cell_text));
1073                apply_column_alignment(&mut para, alignments.get(col_idx).copied().flatten());
1074                if let Some(attrs) = cell_attrs {
1075                    AdfNode::table_header_with_attrs(vec![para], attrs)
1076                } else {
1077                    AdfNode::table_header(vec![para])
1078                }
1079            })
1080            .collect();
1081        if header_adf_cells.is_empty() {
1082            warn!(
1083                "Pipe table header row at line {} has no cells",
1084                self.pos - 1
1085            );
1086        }
1087        rows.push(AdfNode::table_row(header_adf_cells));
1088
1089        // Body rows
1090        while !self.at_end() {
1091            let line = self.current_line();
1092            if !line.contains('|') || line.trim().is_empty() {
1093                break;
1094            }
1095
1096            let cells = parse_table_row(line);
1097            let adf_cells: Vec<AdfNode> = cells
1098                .iter()
1099                .enumerate()
1100                .map(|(col_idx, cell)| {
1101                    let (cell_text, cell_attrs) = extract_cell_attrs(cell);
1102                    let mut para = AdfNode::paragraph(parse_inline(&cell_text));
1103                    apply_column_alignment(&mut para, alignments.get(col_idx).copied().flatten());
1104                    if let Some(attrs) = cell_attrs {
1105                        AdfNode::table_cell_with_attrs(vec![para], attrs)
1106                    } else {
1107                        AdfNode::table_cell(vec![para])
1108                    }
1109                })
1110                .collect();
1111            if adf_cells.is_empty() {
1112                warn!("Pipe table body row at line {} has no cells", self.pos);
1113            }
1114            rows.push(AdfNode::table_row(adf_cells));
1115            self.advance();
1116        }
1117
1118        debug!("Parsed pipe table with {} rows", rows.len());
1119        let mut table = AdfNode::table(rows);
1120
1121        // Check for trailing {attrs} on the next line
1122        if !self.at_end() {
1123            let next = self.current_line().trim();
1124            if next.starts_with('{') {
1125                if let Some((_, attrs)) = parse_attrs(next, 0) {
1126                    let mut table_attrs = serde_json::json!({});
1127                    if let Some(layout) = attrs.get("layout") {
1128                        table_attrs["layout"] = serde_json::Value::String(layout.to_string());
1129                    }
1130                    if attrs.has_flag("numbered") {
1131                        table_attrs["isNumberColumnEnabled"] = serde_json::json!(true);
1132                    } else if attrs.get("numbered") == Some("false") {
1133                        table_attrs["isNumberColumnEnabled"] = serde_json::json!(false);
1134                    }
1135                    if let Some(tw) = attrs.get("width") {
1136                        if let Some(w) = parse_numeric_attr(tw) {
1137                            table_attrs["width"] = w;
1138                        }
1139                    }
1140                    if let Some(local_id) = attrs.get("localId") {
1141                        table_attrs["localId"] = serde_json::Value::String(local_id.to_string());
1142                    }
1143                    if table_attrs != serde_json::json!({}) {
1144                        table.attrs = Some(table_attrs);
1145                        self.advance(); // consume the attrs line
1146                    }
1147                }
1148            }
1149        }
1150
1151        Ok(Some(table))
1152    }
1153
1154    fn parse_paragraph(&mut self) -> Result<AdfNode> {
1155        let mut lines: Vec<&str> = Vec::new();
1156
1157        while !self.at_end() {
1158            let line = self.current_line();
1159            // Only break on block-level patterns if we already have paragraph
1160            // content. This prevents infinite loops when a line looks like a
1161            // block starter but doesn't actually match any block parser (e.g.,
1162            // "#NoSpace" which is not a valid heading).
1163            // Issue #494: A whitespace-only line that follows a hardBreak
1164            // marker (trailing backslash or two trailing spaces) is a
1165            // continuation, not a paragraph break.  Let it fall through to
1166            // the `is_hardbreak_cont` check below.
1167            if (line.trim().is_empty()
1168                && !lines
1169                    .last()
1170                    .is_some_and(|prev| has_trailing_hard_break(prev)))
1171                || is_code_fence_opener(line)
1172                || (is_horizontal_rule(line) && !lines.is_empty())
1173            {
1174                break;
1175            }
1176            // Strip 2-space indent from hardBreak continuation lines so
1177            // the content round-trips correctly (issue #455).
1178            let is_hardbreak_cont = !lines.is_empty()
1179                && line.starts_with("  ")
1180                && lines
1181                    .last()
1182                    .is_some_and(|prev| has_trailing_hard_break(prev));
1183            if is_hardbreak_cont {
1184                lines.push(&line[2..]);
1185                self.advance();
1186                continue;
1187            }
1188            if !lines.is_empty()
1189                && (line.starts_with('#') || line.starts_with('>') || is_list_start(line))
1190            {
1191                break;
1192            }
1193            // Break on trailing block attrs like {align=center}
1194            if !lines.is_empty() && is_block_attrs_line(line) {
1195                break;
1196            }
1197            lines.push(line);
1198            self.advance();
1199        }
1200
1201        let text = lines.join("\n");
1202        let inline_nodes = parse_inline(&text);
1203        Ok(AdfNode::paragraph(inline_nodes))
1204    }
1205}
1206
1207/// Builds ADF cell attributes from JFM directive attrs.
1208/// Maps: `bg` → `background`, `colspan` → number, `rowspan` → number, `colwidth` → array.
1209fn build_cell_attrs(attrs: &crate::atlassian::attrs::Attrs) -> serde_json::Value {
1210    let mut adf = serde_json::json!({});
1211    if let Some(bg) = attrs.get("bg") {
1212        adf["background"] = serde_json::Value::String(bg.to_string());
1213    }
1214    if let Some(colspan) = attrs.get("colspan") {
1215        if let Ok(n) = colspan.parse::<u32>() {
1216            adf["colspan"] = serde_json::json!(n);
1217        }
1218    }
1219    if let Some(rowspan) = attrs.get("rowspan") {
1220        if let Ok(n) = rowspan.parse::<u32>() {
1221            adf["rowspan"] = serde_json::json!(n);
1222        }
1223    }
1224    if let Some(colwidth) = attrs.get("colwidth") {
1225        let widths: Vec<serde_json::Value> = colwidth
1226            .split(',')
1227            .filter_map(|s| parse_numeric_attr(s.trim()))
1228            .collect();
1229        if !widths.is_empty() {
1230            adf["colwidth"] = serde_json::Value::Array(widths);
1231        }
1232    }
1233    if let Some(local_id) = attrs.get("localId") {
1234        adf["localId"] = serde_json::Value::String(local_id.to_string());
1235    }
1236    adf
1237}
1238
1239/// Extracts border marks from directive attributes (used by table cells and media nodes).
1240fn build_border_marks(attrs: &crate::atlassian::attrs::Attrs) -> Vec<AdfMark> {
1241    let mut marks = Vec::new();
1242    let border_color = attrs.get("border-color");
1243    let border_size = attrs.get("border-size");
1244    if border_color.is_some() || border_size.is_some() {
1245        let color = border_color.unwrap_or("#000000");
1246        let size = border_size.and_then(|s| s.parse::<u32>().ok()).unwrap_or(1);
1247        marks.push(AdfMark::border(color, size));
1248    }
1249    marks
1250}
1251
1252/// Converts an ISO 8601 date string (e.g., "2026-04-15") to epoch milliseconds string.
1253/// If the input is already numeric (epoch ms), returns it unchanged.
1254fn iso_date_to_epoch_ms(date_str: &str) -> String {
1255    // If it's already a numeric timestamp, pass through
1256    if date_str.chars().all(|c| c.is_ascii_digit()) {
1257        return date_str.to_string();
1258    }
1259    if let Ok(date) = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
1260        let epoch_ms = date
1261            .and_hms_opt(0, 0, 0)
1262            .map_or(0, |dt| dt.and_utc().timestamp_millis());
1263        epoch_ms.to_string()
1264    } else {
1265        // Fallback: pass through as-is
1266        date_str.to_string()
1267    }
1268}
1269
1270/// Converts an epoch milliseconds string to an ISO 8601 date string.
1271/// If the input looks like an ISO date already, returns it unchanged.
1272fn epoch_ms_to_iso_date(timestamp: &str) -> String {
1273    // If it looks like an ISO date already, pass through
1274    if timestamp.contains('-') {
1275        return timestamp.to_string();
1276    }
1277    if let Ok(ms) = timestamp.parse::<i64>() {
1278        let secs = ms / 1000;
1279        if let Some(dt) = chrono::DateTime::from_timestamp(secs, 0) {
1280            return dt.format("%Y-%m-%d").to_string();
1281        }
1282    }
1283    // Fallback: pass through
1284    timestamp.to_string()
1285}
1286
1287/// Checks if a line is a standalone block-level attrs line like `{align=center}`.
1288fn is_block_attrs_line(line: &str) -> bool {
1289    let trimmed = line.trim();
1290    if !trimmed.starts_with('{') || !trimmed.ends_with('}') {
1291        return false;
1292    }
1293    if let Some((_, attrs)) = parse_attrs(trimmed, 0) {
1294        // Only consider it a block attrs line if it has recognized block attrs
1295        attrs.get("align").is_some()
1296            || attrs.get("indent").is_some()
1297            || attrs.get("breakout").is_some()
1298            || attrs.get("breakoutWidth").is_some()
1299            || attrs.get("localId").is_some()
1300    } else {
1301        false
1302    }
1303}
1304
1305/// Parses decision items from the inner content of a `:::decisions` container.
1306/// Each item starts with `- <> ` prefix.
1307fn parse_decision_items(text: &str) -> Vec<AdfNode> {
1308    let mut items = Vec::new();
1309    for line in text.lines() {
1310        let trimmed = line.trim();
1311        if let Some(rest) = trimmed.strip_prefix("- <> ") {
1312            let inline_nodes = parse_inline(rest);
1313            items.push(AdfNode::decision_item("DECIDED", inline_nodes));
1314        }
1315    }
1316    items
1317}
1318
1319/// Tries to parse a task list marker `[ ]`, `[x]`, or `[X]` at the start of text.
1320/// Returns `("TODO"|"DONE", remaining_text)` on success.
1321///
1322/// The marker must be followed by a space or the end of the text, so that
1323/// empty task items (`- [ ]` with no body) are still recognised as tasks
1324/// rather than being treated as bullet items containing literal `[ ]` text
1325/// (issue #548).
1326fn try_parse_task_marker(text: &str) -> Option<(&str, &str)> {
1327    if let Some(rest) = strip_task_checkbox(text, "[ ]") {
1328        Some(("TODO", rest))
1329    } else if let Some(rest) =
1330        strip_task_checkbox(text, "[x]").or_else(|| strip_task_checkbox(text, "[X]"))
1331    {
1332        Some(("DONE", rest))
1333    } else {
1334        None
1335    }
1336}
1337
1338/// Strips a checkbox prefix from `text` if the character after the checkbox
1339/// is a space or the text ends there.  Returns the remaining text (with the
1340/// separating space consumed, if any).
1341fn strip_task_checkbox<'a>(text: &'a str, checkbox: &str) -> Option<&'a str> {
1342    let rest = text.strip_prefix(checkbox)?;
1343    if rest.is_empty() {
1344        Some(rest)
1345    } else {
1346        rest.strip_prefix(' ')
1347    }
1348}
1349
1350/// Returns true if `s` begins with a sequence the bullet-list parser would
1351/// interpret as a task checkbox marker (`[ ]`, `[x]`, or `[X]` followed by
1352/// a space, newline, or end-of-input).
1353///
1354/// Used by the `bulletList` renderer to decide whether to escape the leading
1355/// `[` of an item whose literal text starts with a checkbox-shaped prefix
1356/// (issue #548).
1357fn starts_with_task_marker(s: &str) -> bool {
1358    let after = if let Some(rest) = s.strip_prefix("[ ]") {
1359        rest
1360    } else if let Some(rest) = s.strip_prefix("[x]").or_else(|| s.strip_prefix("[X]")) {
1361        rest
1362    } else {
1363        return false;
1364    };
1365    after.is_empty() || after.starts_with(' ') || after.starts_with('\n')
1366}
1367
1368/// Parses an ordered list marker like "1. " and returns (number, rest_of_line).
1369fn parse_ordered_list_marker(line: &str) -> Option<(u32, &str)> {
1370    let digit_end = line.find(|c: char| !c.is_ascii_digit())?;
1371    if digit_end == 0 {
1372        return None;
1373    }
1374    let rest = &line[digit_end..];
1375    let after_marker = rest.strip_prefix(". ")?;
1376    let num: u32 = line[..digit_end].parse().ok()?;
1377    Some((num, after_marker))
1378}
1379
1380/// Returns true if a line ends with a hardBreak marker
1381/// (trailing backslash or two trailing spaces).
1382fn has_trailing_hard_break(line: &str) -> bool {
1383    line.ends_with('\\') || line.ends_with("  ")
1384}
1385
1386/// Returns true if the already-trimmed continuation line starts with a
1387/// block-level marker that must not be swallowed as a paragraph continuation
1388/// in `collect_hardbreak_continuations`.
1389///
1390/// Covers mediaSingle (`![` — issue #490), fenced code blocks (```` ``` ````
1391/// — issue #552), and container directives (`:::`).  The caller is expected
1392/// to have already stripped leading whitespace.
1393fn is_block_level_continuation_marker(trimmed: &str) -> bool {
1394    trimmed.starts_with("![") || trimmed.starts_with("```") || trimmed.starts_with(":::")
1395}
1396
1397/// Checks if a line starts a list item.
1398fn is_list_start(line: &str) -> bool {
1399    let trimmed = line.trim_start();
1400    trimmed.starts_with("- ")
1401        || trimmed.starts_with("* ")
1402        || trimmed.starts_with("+ ")
1403        || parse_ordered_list_marker(trimmed).is_some()
1404}
1405
1406/// Escapes asterisk and underscore sequences in text that would otherwise be
1407/// parsed as CommonMark emphasis (`*…*`, `_…_`) or strong emphasis (`**…**`,
1408/// `__…__`).
1409///
1410/// Asterisks are always escaped (they're rare in prose and the JFM parser
1411/// will gladly match them across node boundaries). Underscores are escaped
1412/// per the intraword rule: a `_` is left as-is only when it's clearly
1413/// intraword *within this text node* (alphanumeric on both sides). At the
1414/// node boundary or next to non-alphanumeric characters we escape, since
1415/// adjacent text nodes can supply the other side of an emphasis pair (issue
1416/// #554: `"_ "` followed by colored `"_Action…"` produced `_ :span[_…` which
1417/// parsed as italic and destroyed the span directive).
1418fn escape_emphasis_markers(text: &str) -> String {
1419    escape_emphasis_with(text, false)
1420}
1421
1422/// Variant of [`escape_emphasis_markers`] that escapes ALL underscores (even
1423/// intraword), not just boundary ones.
1424///
1425/// Must be used whenever the rendered markdown wraps this text in an `_..._`
1426/// em delimiter, because an unescaped `_` anywhere in the content would
1427/// otherwise close the delimiter prematurely (e.g. `_foo_bar_baz_` parses as
1428/// em("foo") + "bar" + em("baz"), not em("foo_bar_baz")).
1429fn escape_emphasis_markers_with_underscore(text: &str) -> String {
1430    escape_emphasis_with(text, true)
1431}
1432
1433/// Internal: escapes `*` always, and escapes `_` per the CommonMark intraword
1434/// rule by default — boundary or punctuation-adjacent runs are escaped, fully
1435/// intraword runs are left as-is.  When `escape_underscore_always` is true,
1436/// every `_` is escaped regardless (used when the surrounding context is an
1437/// `_..._` em delimiter that any inner `_` would close prematurely).
1438fn escape_emphasis_with(text: &str, escape_underscore_always: bool) -> String {
1439    let chars: Vec<char> = text.chars().collect();
1440    let mut out = String::with_capacity(text.len());
1441    let mut idx = 0;
1442    while idx < chars.len() {
1443        let ch = chars[idx];
1444        if ch == '*' {
1445            out.push('\\');
1446            out.push(ch);
1447            idx += 1;
1448        } else if ch == '_' {
1449            // Find the extent of this run of underscores. CommonMark treats
1450            // consecutive `_` as a single delimiter run, so the intraword
1451            // check applies to the whole run, not individual characters.
1452            let run_start = idx;
1453            let mut run_end = idx;
1454            while run_end < chars.len() && chars[run_end] == '_' {
1455                run_end += 1;
1456            }
1457            let escape_run = if escape_underscore_always {
1458                true
1459            } else {
1460                let before_alnum = run_start > 0 && chars[run_start - 1].is_alphanumeric();
1461                let after_alnum = chars.get(run_end).is_some_and(|c| c.is_alphanumeric());
1462                !(before_alnum && after_alnum)
1463            };
1464            for _ in run_start..run_end {
1465                if escape_run {
1466                    out.push('\\');
1467                }
1468                out.push('_');
1469            }
1470            idx = run_end;
1471        } else {
1472            out.push(ch);
1473            idx += 1;
1474        }
1475    }
1476    out
1477}
1478
1479/// Escapes backtick characters in text that would otherwise be parsed as
1480/// inline code spans (`` `…` ``).
1481///
1482/// Each backtick is prefixed with a backslash so that the JFM parser treats
1483/// it as a literal character rather than an inline-code delimiter.
1484fn escape_backticks(text: &str) -> String {
1485    let mut out = String::with_capacity(text.len());
1486    for ch in text.chars() {
1487        if ch == '`' {
1488            out.push('\\');
1489        }
1490        out.push(ch);
1491    }
1492    out
1493}
1494
1495/// Chooses a backtick delimiter length and padding flag for rendering `text`
1496/// as a CommonMark inline code span.
1497///
1498/// Per CommonMark: the delimiter must be a run of backticks not equal in
1499/// length to any run inside the content, and if both ends of the content
1500/// would start/end with a space (or with a backtick), a single space of
1501/// padding is added so the span survives the spec's space-stripping rule.
1502fn inline_code_delimiter(text: &str) -> (usize, bool) {
1503    let mut max_run = 0usize;
1504    let mut current = 0usize;
1505    for ch in text.chars() {
1506        if ch == '`' {
1507            current += 1;
1508            if current > max_run {
1509                max_run = current;
1510            }
1511        } else {
1512            current = 0;
1513        }
1514    }
1515    let n = max_run + 1;
1516    let starts_bt = text.starts_with('`');
1517    let ends_bt = text.ends_with('`');
1518    let starts_sp = text.starts_with(' ');
1519    let ends_sp = text.ends_with(' ');
1520    let all_sp = !text.is_empty() && text.chars().all(|c| c == ' ');
1521    let needs_pad = starts_bt || ends_bt || (starts_sp && ends_sp && !all_sp);
1522    (n, needs_pad)
1523}
1524
1525/// Appends `text` to `output` wrapped in a CommonMark inline code span whose
1526/// delimiter length allows any embedded backticks to round-trip unambiguously.
1527fn render_inline_code(text: &str, output: &mut String) {
1528    let (n, pad) = inline_code_delimiter(text);
1529    for _ in 0..n {
1530        output.push('`');
1531    }
1532    if pad {
1533        output.push(' ');
1534    }
1535    output.push_str(text);
1536    if pad {
1537        output.push(' ');
1538    }
1539    for _ in 0..n {
1540        output.push('`');
1541    }
1542}
1543
1544/// Escapes pipe characters in text that appears inside a GFM pipe table cell.
1545///
1546/// Without this, a literal `|` in cell content (including inside inline code
1547/// spans) is interpreted as a column separator on round-trip, splitting the
1548/// cell and corrupting its content (see issue #579).  Each `|` is prefixed
1549/// with a backslash so the table-row parser treats it as literal.
1550fn escape_pipes_in_cell(text: &str) -> String {
1551    let mut out = String::with_capacity(text.len());
1552    for ch in text.chars() {
1553        if ch == '|' {
1554            out.push('\\');
1555        }
1556        out.push(ch);
1557    }
1558    out
1559}
1560
1561/// Escapes square brackets (`[` and `]`) in text that will appear inside a
1562/// markdown link's `[…]` delimiters.  Without this, a text node containing a
1563/// literal `[` or `]` can create ambiguous markdown link syntax on round-trip
1564/// (see issue #493).
1565fn escape_link_brackets(text: &str) -> String {
1566    let mut out = String::with_capacity(text.len());
1567    for ch in text.chars() {
1568        if ch == '[' || ch == ']' {
1569            out.push('\\');
1570        }
1571        out.push(ch);
1572    }
1573    out
1574}
1575
1576/// Escapes bare URLs (`http://` and `https://`) in plain text so they are not
1577/// parsed as `inlineCard` nodes during round-trip.  The leading `h` is
1578/// backslash-escaped, which is enough to prevent the auto-link detector from
1579/// matching the URL while the existing backslash-escape handler restores it on
1580/// re-parse.
1581fn escape_bare_urls(text: &str) -> String {
1582    let mut result = String::with_capacity(text.len());
1583    for (i, ch) in text.char_indices() {
1584        if ch == 'h' {
1585            let rest = &text[i..];
1586            if rest.starts_with("http://") || rest.starts_with("https://") {
1587                result.push('\\');
1588            }
1589        }
1590        result.push(ch);
1591    }
1592    result
1593}
1594
1595/// Returns `true` if the string can be embedded in a `:card[...]` (or similar
1596/// bracketed inline directive) without breaking the depth-based bracket matcher
1597/// used by [`try_parse_inline_directive`].
1598///
1599/// The parser scans the content enclosed in `[...]` treating `[` as +1 and `]`
1600/// as −1 on depth, closing when depth returns to zero.  A value is safe if
1601/// every prefix has `count('[') >= count(']') − 1` (i.e., the running depth
1602/// never dips below zero before the end) and it contains no newline.
1603fn url_safe_in_bracket_content(s: &str) -> bool {
1604    if s.contains('\n') {
1605        return false;
1606    }
1607    let mut depth: i32 = 1;
1608    for ch in s.chars() {
1609        match ch {
1610            '[' => depth += 1,
1611            ']' => {
1612                depth -= 1;
1613                if depth == 0 {
1614                    return false;
1615                }
1616            }
1617            _ => {}
1618        }
1619    }
1620    true
1621}
1622
1623/// Escapes emoji shortcode patterns (`:name:`) in plain text so they are not
1624/// parsed as emoji nodes during round-trip.  Only the leading colon is
1625/// backslash-escaped, which is enough to prevent the parser from matching the
1626/// pattern while the existing backslash-escape handler restores it on re-parse.
1627///
1628/// The character class for the name segment must match `try_parse_emoji_shortcode`
1629/// exactly (Unicode `is_alphanumeric` plus `_`, `+`, `-`).  An ASCII-only escape
1630/// would leave Unicode patterns like `:Café:` or `:ZBC::Acme::配置:` un-escaped
1631/// while still being detected as emoji on re-parse, splitting the text node
1632/// (issue #552).
1633fn escape_emoji_shortcodes(text: &str) -> String {
1634    let mut result = String::with_capacity(text.len());
1635
1636    for (i, ch) in text.char_indices() {
1637        if ch == ':' {
1638            // Check if this is a `:name:` pattern where name matches the
1639            // same character class accepted by `try_parse_emoji_shortcode`.
1640            let after = i + 1;
1641            if after < text.len() {
1642                let name_end = text[after..]
1643                    .find(|c: char| !c.is_alphanumeric() && c != '_' && c != '+' && c != '-')
1644                    .map_or(text[after..].len(), |pos| pos);
1645                if name_end > 0
1646                    && after + name_end < text.len()
1647                    && text.as_bytes()[after + name_end] == b':'
1648                {
1649                    // Found `:name:` pattern — escape the leading colon
1650                    result.push('\\');
1651                }
1652            }
1653        }
1654        result.push(ch);
1655    }
1656
1657    result
1658}
1659
1660/// Escapes a leading list-marker pattern on a line so it is not
1661/// re-parsed as a new list item.  `"2. text"` → `"2\. text"`,
1662/// `"- text"` → `"\- text"`.
1663fn escape_list_marker(line: &str) -> String {
1664    if let Some(dot_pos) = line.find(". ") {
1665        if parse_ordered_list_marker(line).is_some() {
1666            let mut s = String::with_capacity(line.len() + 1);
1667            s.push_str(&line[..dot_pos]);
1668            s.push('\\');
1669            s.push_str(&line[dot_pos..]);
1670            return s;
1671        }
1672    }
1673    for prefix in &["- ", "* ", "+ "] {
1674        if line.starts_with(prefix) {
1675            let mut s = String::with_capacity(line.len() + 1);
1676            s.push('\\');
1677            s.push_str(line);
1678            return s;
1679        }
1680    }
1681    line.to_string()
1682}
1683
1684/// Checks if a line is a valid fenced code block opener.
1685///
1686/// Per CommonMark: the opener is a sequence of three or more backticks
1687/// followed by an info string that must not contain any backtick
1688/// character, otherwise some inline code spans would be misinterpreted
1689/// as the beginning of a fenced code block.
1690fn is_code_fence_opener(line: &str) -> bool {
1691    if !line.starts_with("```") {
1692        return false;
1693    }
1694    !line[3..].contains('`')
1695}
1696
1697/// Checks if a line is a horizontal rule.
1698fn is_horizontal_rule(line: &str) -> bool {
1699    let trimmed = line.trim();
1700    trimmed.len() >= 3
1701        && ((trimmed.starts_with("---") && trimmed.chars().all(|c| c == '-'))
1702            || (trimmed.starts_with("***") && trimmed.chars().all(|c| c == '*'))
1703            || (trimmed.starts_with("___") && trimmed.chars().all(|c| c == '_')))
1704}
1705
1706/// Checks if a line is a GFM table separator (e.g., "|---|---|").
1707fn is_table_separator(line: &str) -> bool {
1708    let trimmed = line.trim();
1709    trimmed.contains('|')
1710        && trimmed
1711            .chars()
1712            .all(|c| c == '|' || c == '-' || c == ':' || c == ' ')
1713}
1714
1715/// Parses a GFM table row into cell contents.
1716///
1717/// Splits on unescaped `|` characters; a preceding backslash (`\|`) is
1718/// interpreted as a literal pipe and unescaped in the emitted cell content
1719/// (see issue #579).  This allows code spans and other inline content that
1720/// contain literal `|` to survive round-trip through a pipe table.
1721fn parse_table_row(line: &str) -> Vec<String> {
1722    let trimmed = line.trim();
1723    let trimmed = trimmed.strip_prefix('|').unwrap_or(trimmed);
1724    let trimmed = trimmed.strip_suffix('|').unwrap_or(trimmed);
1725
1726    let mut cells: Vec<String> = Vec::new();
1727    let mut current = String::new();
1728    let mut chars = trimmed.chars().peekable();
1729    while let Some(ch) = chars.next() {
1730        if ch == '\\' && chars.peek() == Some(&'|') {
1731            current.push('|');
1732            chars.next();
1733        } else if ch == '|' {
1734            cells.push(std::mem::take(&mut current));
1735        } else {
1736            current.push(ch);
1737        }
1738    }
1739    cells.push(current);
1740
1741    cells
1742        .iter()
1743        .map(|s| {
1744            // Strip exactly one leading and one trailing space (pipe table padding).
1745            // Preserve any additional whitespace as significant content.
1746            let stripped = s.strip_prefix(' ').unwrap_or(s.as_str());
1747            let stripped = stripped.strip_suffix(' ').unwrap_or(stripped);
1748            stripped.to_string()
1749        })
1750        .collect()
1751}
1752
1753/// Parses column alignments from a GFM table separator row.
1754/// Returns a vec of `Option<&str>` where `Some("center")` or `Some("end")` indicate alignment.
1755fn parse_table_alignments(separator_line: &str) -> Vec<Option<&'static str>> {
1756    let trimmed = separator_line.trim();
1757    let trimmed = trimmed.strip_prefix('|').unwrap_or(trimmed);
1758    let trimmed = trimmed.strip_suffix('|').unwrap_or(trimmed);
1759
1760    trimmed
1761        .split('|')
1762        .map(|cell| {
1763            let cell = cell.trim();
1764            let starts_colon = cell.starts_with(':');
1765            let ends_colon = cell.ends_with(':');
1766            match (starts_colon, ends_colon) {
1767                (true, true) => Some("center"),
1768                (false, true) => Some("end"),
1769                _ => None, // left/default
1770            }
1771        })
1772        .collect()
1773}
1774
1775/// Applies an alignment mark to a paragraph node if alignment is specified.
1776fn apply_column_alignment(para: &mut AdfNode, alignment: Option<&str>) {
1777    if let Some(align) = alignment {
1778        para.marks = Some(vec![AdfMark::alignment(align)]);
1779    }
1780}
1781
1782/// Extracts `{attrs}` prefix from a pipe table cell text.
1783/// Returns `(remaining_text, Option<adf_attrs_json>)`.
1784fn extract_cell_attrs(cell_text: &str) -> (String, Option<serde_json::Value>) {
1785    let trimmed = cell_text.trim_start();
1786    if !trimmed.starts_with('{') {
1787        return (cell_text.to_string(), None);
1788    }
1789    if let Some((end_pos, attrs)) = parse_attrs(trimmed, 0) {
1790        let remaining = trimmed[end_pos..].trim_start().to_string();
1791        let adf_attrs = build_cell_attrs(&attrs);
1792        (remaining, Some(adf_attrs))
1793    } else {
1794        (cell_text.to_string(), None)
1795    }
1796}
1797
1798/// Tries to parse a line as a block-level image and return a mediaSingle ADF node.
1799/// Used by both `try_image` (top-level blocks) and list item parsing.
1800fn try_parse_media_single_from_line(line: &str) -> Option<AdfNode> {
1801    let line = line.trim();
1802    if !line.starts_with("![") {
1803        return None;
1804    }
1805
1806    let (alt, url) = parse_image_syntax(line)?;
1807    let alt_opt = if alt.is_empty() { None } else { Some(alt) };
1808
1809    let paren_open = line.find("](")? + 1; // index of '('
1810    let img_end = find_closing_paren(line, paren_open)? + 1;
1811    let after_img = line[img_end..].trim_start();
1812
1813    if after_img.starts_with('{') {
1814        if let Some((_, attrs)) = parse_attrs(after_img, 0) {
1815            // Confluence file attachment — reconstruct type:file media node
1816            if attrs.get("type") == Some("file") || attrs.get("id").is_some() {
1817                let mut media_attrs = serde_json::json!({"type": "file"});
1818                if let Some(id) = attrs.get("id") {
1819                    media_attrs["id"] = serde_json::Value::String(id.to_string());
1820                }
1821                if let Some(collection) = attrs.get("collection") {
1822                    media_attrs["collection"] = serde_json::Value::String(collection.to_string());
1823                }
1824                if let Some(occurrence_key) = attrs.get("occurrenceKey") {
1825                    media_attrs["occurrenceKey"] =
1826                        serde_json::Value::String(occurrence_key.to_string());
1827                }
1828                if let Some(height) = attrs.get("height") {
1829                    if let Some(h) = parse_numeric_attr(height) {
1830                        media_attrs["height"] = h;
1831                    }
1832                }
1833                if let Some(width) = attrs.get("width") {
1834                    if let Some(w) = parse_numeric_attr(width) {
1835                        media_attrs["width"] = w;
1836                    }
1837                }
1838                if let Some(alt_text) = alt_opt {
1839                    media_attrs["alt"] = serde_json::Value::String(alt_text.to_string());
1840                }
1841                if let Some(local_id) = attrs.get("localId") {
1842                    media_attrs["localId"] = serde_json::Value::String(local_id.to_string());
1843                }
1844                let mut ms_attrs = serde_json::json!({"layout": "center"});
1845                if let Some(layout) = attrs.get("layout") {
1846                    ms_attrs["layout"] = serde_json::Value::String(layout.to_string());
1847                }
1848                if let Some(ms_width) = attrs.get("mediaWidth") {
1849                    if let Some(w) = parse_numeric_attr(ms_width) {
1850                        ms_attrs["width"] = w;
1851                    }
1852                }
1853                if let Some(wt) = attrs.get("widthType") {
1854                    ms_attrs["widthType"] = serde_json::Value::String(wt.to_string());
1855                }
1856                if let Some(mode) = attrs.get("mode") {
1857                    ms_attrs["mode"] = serde_json::Value::String(mode.to_string());
1858                }
1859                let border_marks = build_border_marks(&attrs);
1860                let media_marks = if border_marks.is_empty() {
1861                    None
1862                } else {
1863                    Some(border_marks)
1864                };
1865                return Some(AdfNode {
1866                    node_type: "mediaSingle".to_string(),
1867                    attrs: Some(ms_attrs),
1868                    content: Some(vec![AdfNode {
1869                        node_type: "media".to_string(),
1870                        attrs: Some(media_attrs),
1871                        content: None,
1872                        text: None,
1873                        marks: media_marks,
1874                        local_id: None,
1875                        parameters: None,
1876                    }]),
1877                    text: None,
1878                    marks: None,
1879                    local_id: None,
1880                    parameters: None,
1881                });
1882            }
1883
1884            // External image — apply layout/width/widthType to mediaSingle attrs
1885            let mut node = AdfNode::media_single(url, alt_opt);
1886            if let Some(ref mut node_attrs) = node.attrs {
1887                if let Some(layout) = attrs.get("layout") {
1888                    node_attrs["layout"] = serde_json::Value::String(layout.to_string());
1889                }
1890                if let Some(width) = attrs.get("width") {
1891                    if let Some(w) = parse_numeric_attr(width) {
1892                        node_attrs["width"] = w;
1893                    }
1894                }
1895                if let Some(wt) = attrs.get("widthType") {
1896                    node_attrs["widthType"] = serde_json::Value::String(wt.to_string());
1897                }
1898                if let Some(mode) = attrs.get("mode") {
1899                    node_attrs["mode"] = serde_json::Value::String(mode.to_string());
1900                }
1901            }
1902            if let Some(ref mut content) = node.content {
1903                if let Some(media) = content.first_mut() {
1904                    if let Some(local_id) = attrs.get("localId") {
1905                        if let Some(ref mut media_attrs) = media.attrs {
1906                            media_attrs["localId"] =
1907                                serde_json::Value::String(local_id.to_string());
1908                        }
1909                    }
1910                    let border_marks = build_border_marks(&attrs);
1911                    if !border_marks.is_empty() {
1912                        media.marks = Some(border_marks);
1913                    }
1914                }
1915            }
1916            return Some(node);
1917        }
1918    }
1919
1920    Some(AdfNode::media_single(url, alt_opt))
1921}
1922
1923/// Parses `![alt](url)` image syntax.
1924fn parse_image_syntax(line: &str) -> Option<(&str, &str)> {
1925    let line = line.trim();
1926    if !line.starts_with("![") {
1927        return None;
1928    }
1929
1930    let alt_end = line.find("](")?;
1931    let alt = &line[2..alt_end];
1932    let paren_start = alt_end + 1; // index of the '('
1933    let url_end = find_closing_paren(line, paren_start)?;
1934    let url = &line[paren_start + 1..url_end];
1935
1936    Some((alt, url))
1937}
1938
1939// ── Inline Parsing ──────────────────────────────────────────────────
1940
1941/// Parses inline markdown content into ADF inline nodes.
1942///
1943/// Detects bare URLs (e.g., `https://example.com`) and promotes them to
1944/// `inlineCard` nodes. Call this at the top level (paragraph, heading, cell,
1945/// list item) where a bare URL represents a smart link.
1946fn parse_inline(text: &str) -> Vec<AdfNode> {
1947    parse_inline_impl(text, true, 0)
1948}
1949
1950/// Implementation backing [`parse_inline`] and the inline recursion sites.
1951///
1952/// When `auto_inline_card` is `false`, bare `http://`/`https://` URLs are
1953/// treated as plain text instead of being promoted to `inlineCard` nodes.
1954/// Recursion into mark-wrapping constructs (emphasis, strike, bracketed
1955/// spans, links, inline directives) passes `false`: the enclosing syntax
1956/// already declares the semantic role of the content — a URL inside
1957/// `[url]{underline}` or `**url**` is the user's text, not a smart link
1958/// (issue #553).
1959///
1960/// `depth` counts those recursion levels.  Past [`MAX_NESTING_DEPTH`] the
1961/// remaining text is emitted as a plain text node instead of recursing
1962/// further — inline parsing is infallible, so unlike the block parser it
1963/// degrades gracefully rather than erroring (issue #1130).
1964fn parse_inline_impl(text: &str, auto_inline_card: bool, depth: usize) -> Vec<AdfNode> {
1965    if depth > MAX_NESTING_DEPTH {
1966        return if text.is_empty() {
1967            Vec::new()
1968        } else {
1969            vec![AdfNode::text(text)]
1970        };
1971    }
1972
1973    let mut nodes = Vec::new();
1974    let mut chars = text.char_indices().peekable();
1975    let mut plain_start = 0;
1976
1977    while let Some(&(i, ch)) = chars.peek() {
1978        match ch {
1979            '*' | '_' => {
1980                if let Some((end, content, is_bold)) = try_parse_emphasis(text, i) {
1981                    flush_plain(text, plain_start, i, &mut nodes);
1982                    let mark = if is_bold {
1983                        AdfMark::strong()
1984                    } else {
1985                        AdfMark::em()
1986                    };
1987                    let inner = parse_inline_impl(content, false, depth + 1);
1988                    for mut node in inner {
1989                        prepend_mark(&mut node, mark.clone());
1990                        nodes.push(node);
1991                    }
1992                    // Advance past the consumed characters
1993                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
1994                        chars.next();
1995                    }
1996                    plain_start = end;
1997                    continue;
1998                }
1999                // For underscores, skip the entire delimiter run so that
2000                // individual `_` chars within a `__` or `___` run are not
2001                // re-tried as separate emphasis openers (CommonMark treats
2002                // consecutive underscores as a single delimiter run).
2003                if ch == '_' {
2004                    while chars.peek().is_some_and(|&(_, c)| c == '_') {
2005                        chars.next();
2006                    }
2007                } else {
2008                    chars.next();
2009                }
2010            }
2011            '~' => {
2012                if let Some((end, content)) = try_parse_strikethrough(text, i) {
2013                    flush_plain(text, plain_start, i, &mut nodes);
2014                    let inner = parse_inline_impl(content, false, depth + 1);
2015                    for mut node in inner {
2016                        prepend_mark(&mut node, AdfMark::strike());
2017                        nodes.push(node);
2018                    }
2019                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2020                        chars.next();
2021                    }
2022                    plain_start = end;
2023                    continue;
2024                }
2025                chars.next();
2026            }
2027            '`' => {
2028                if let Some((end, content)) = try_parse_inline_code(text, i) {
2029                    flush_plain(text, plain_start, i, &mut nodes);
2030                    nodes.push(AdfNode::text_with_marks(content, vec![AdfMark::code()]));
2031                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2032                        chars.next();
2033                    }
2034                    plain_start = end;
2035                    continue;
2036                }
2037                // No code span starting here; skip past the entire backtick
2038                // run so a longer opening run isn't retried as a shorter one.
2039                while chars.peek().is_some_and(|&(_, c)| c == '`') {
2040                    chars.next();
2041                }
2042            }
2043            '[' => {
2044                if let Some((end, link_text, href)) = try_parse_link(text, i) {
2045                    flush_plain(text, plain_start, i, &mut nodes);
2046                    if link_text.starts_with("http://") || link_text.starts_with("https://") {
2047                        // URL-as-link-text: emit as text with link mark,
2048                        // not via parse_inline which would produce an inlineCard.
2049                        // Covers both exact matches and trailing-slash mismatches
2050                        // (issue #523).
2051                        nodes.push(AdfNode::text_with_marks(
2052                            link_text,
2053                            vec![AdfMark::link(href)],
2054                        ));
2055                    } else {
2056                        let inner = parse_inline_impl(link_text, false, depth + 1);
2057                        for mut node in inner {
2058                            prepend_mark(&mut node, AdfMark::link(href));
2059                            nodes.push(node);
2060                        }
2061                    }
2062                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2063                        chars.next();
2064                    }
2065                    plain_start = end;
2066                    continue;
2067                }
2068                // Try bracketed span with attributes: [text]{underline}
2069                if let Some((end, span_nodes)) = try_parse_bracketed_span(text, i, depth) {
2070                    flush_plain(text, plain_start, i, &mut nodes);
2071                    nodes.extend(span_nodes);
2072                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2073                        chars.next();
2074                    }
2075                    plain_start = end;
2076                    continue;
2077                }
2078                chars.next();
2079            }
2080            ':' => {
2081                // Try generic inline directive (:card[url], :status[text]{attrs}, etc.)
2082                if let Some(node) = try_dispatch_inline_directive(text, i, depth) {
2083                    flush_plain(text, plain_start, i, &mut nodes);
2084                    let end = node.1;
2085                    nodes.push(node.0);
2086                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2087                        chars.next();
2088                    }
2089                    plain_start = end;
2090                    continue;
2091                }
2092                // Try emoji shortcode :name: with optional {attrs}
2093                if let Some((end, short_name)) = try_parse_emoji_shortcode(text, i) {
2094                    flush_plain(text, plain_start, i, &mut nodes);
2095                    let (final_end, emoji_node) = parse_emoji_with_attrs(text, end, short_name);
2096                    nodes.push(emoji_node);
2097                    while chars.peek().is_some_and(|&(idx, _)| idx < final_end) {
2098                        chars.next();
2099                    }
2100                    plain_start = final_end;
2101                    continue;
2102                }
2103                chars.next();
2104            }
2105            ' ' if text[i..].starts_with("  \n") => {
2106                // Trailing-space line break → hardBreak node.
2107                // Flush preceding text (without the trailing spaces).
2108                flush_plain(text, plain_start, i, &mut nodes);
2109                nodes.push(AdfNode::hard_break());
2110                // Skip past all spaces and the newline
2111                while chars.peek().is_some_and(|&(_, c)| c == ' ') {
2112                    chars.next();
2113                }
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 text[i..].starts_with("![") => {
2121                // Inline image — skip the ! and let [ handle it next iteration
2122                // (Images at block level are handled by try_image; inline images
2123                // degrade to link text in ADF since inline media is complex)
2124                chars.next();
2125            }
2126            'h' if auto_inline_card
2127                && (text[i..].starts_with("http://") || text[i..].starts_with("https://")) =>
2128            {
2129                if let Some((end, url)) = try_parse_bare_url(text, i) {
2130                    flush_plain(text, plain_start, i, &mut nodes);
2131                    nodes.push(AdfNode::inline_card(url));
2132                    while chars.peek().is_some_and(|&(idx, _)| idx < end) {
2133                        chars.next();
2134                    }
2135                    plain_start = end;
2136                    continue;
2137                }
2138                chars.next();
2139            }
2140            '\\' if text.as_bytes().get(i + 1) == Some(&b'n')
2141                && text.as_bytes().get(i + 2) != Some(&b'\n') =>
2142            {
2143                // Issue #454: `\n` (backslash + letter n) encodes a literal
2144                // newline inside a text node. Emit the newline as a separate
2145                // text node so merge_adjacent_text can reassemble it.
2146                flush_plain(text, plain_start, i, &mut nodes);
2147                nodes.push(AdfNode::text("\n"));
2148                chars.next(); // consume the '\'
2149                chars.next(); // consume the 'n'
2150                plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2151            }
2152            '\\' if i + 1 < text.len() && !text[i..].starts_with("\\\n") => {
2153                // Backslash escape: skip the backslash and treat the next
2154                // character as literal text (e.g. `\\` → `\`,
2155                // `2\. text` → `2. text`, `\*word\*` → `*word*` without
2156                // emphasis, `\:fire:` → `:fire:` without emoji parsing).
2157                flush_plain(text, plain_start, i, &mut nodes);
2158                chars.next(); // consume the backslash
2159                              // Set plain_start to the escaped character so it is included
2160                              // in the next plain-text run, then advance past it so it is
2161                              // not re-interpreted as a special character (e.g. `*`, `_`, `:`).
2162                plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2163                chars.next(); // consume the escaped character
2164            }
2165            '\\' if text[i..].starts_with("\\\n") => {
2166                // Backslash line break → hardBreak node.
2167                flush_plain(text, plain_start, i, &mut nodes);
2168                nodes.push(AdfNode::hard_break());
2169                chars.next(); // consume the '\'
2170                              // Skip the newline
2171                if chars.peek().is_some_and(|&(_, c)| c == '\n') {
2172                    chars.next();
2173                }
2174                plain_start = chars.peek().map_or(text.len(), |&(idx, _)| idx);
2175            }
2176            '\\' if i + 1 == text.len() => {
2177                // Trailing backslash at end of paragraph text → hardBreak node.
2178                flush_plain(text, plain_start, i, &mut nodes);
2179                nodes.push(AdfNode::hard_break());
2180                chars.next(); // consume the '\'
2181                plain_start = text.len();
2182            }
2183            _ => {
2184                chars.next();
2185            }
2186        }
2187    }
2188
2189    // Flush remaining plain text
2190    if plain_start < text.len() {
2191        let remaining = &text[plain_start..];
2192        if !remaining.is_empty() {
2193            nodes.push(AdfNode::text(remaining));
2194        }
2195    }
2196
2197    // Merge adjacent unmarked text nodes that can arise from backslash
2198    // escape handling (e.g. `"2"` + `". text"` → `"2. text"`).
2199    merge_adjacent_text(&mut nodes);
2200
2201    nodes
2202}
2203
2204/// Merges consecutive unmarked text nodes in-place.
2205fn merge_adjacent_text(nodes: &mut Vec<AdfNode>) {
2206    let mut i = 0;
2207    while i + 1 < nodes.len() {
2208        if nodes[i].node_type == "text"
2209            && nodes[i + 1].node_type == "text"
2210            && nodes[i].marks.is_none()
2211            && nodes[i + 1].marks.is_none()
2212        {
2213            let next_text = nodes[i + 1].text.clone().unwrap_or_default();
2214            if let Some(ref mut t) = nodes[i].text {
2215                t.push_str(&next_text);
2216            }
2217            nodes.remove(i + 1);
2218        } else {
2219            i += 1;
2220        }
2221    }
2222}
2223
2224/// Flushes accumulated plain text as a text node.
2225fn flush_plain(text: &str, start: usize, end: usize, nodes: &mut Vec<AdfNode>) {
2226    if start < end {
2227        let plain = &text[start..end];
2228        if !plain.is_empty() {
2229            nodes.push(AdfNode::text(plain));
2230        }
2231    }
2232}
2233
2234/// Adds a mark to a node (creates marks vec if needed).
2235#[cfg(test)]
2236fn add_mark(node: &mut AdfNode, mark: AdfMark) {
2237    if let Some(ref mut marks) = node.marks {
2238        marks.push(mark);
2239    } else {
2240        node.marks = Some(vec![mark]);
2241    }
2242}
2243
2244/// Removes `code` marks from heading inline content, returning the count
2245/// removed.
2246///
2247/// ADF's `heading` content model forbids the `code` mark (a heading styles
2248/// its own text), but JFM/CommonMark parses inline-code spans inside
2249/// `#`-headings. Stripping here keeps the text as plain — a lossy but safe
2250/// direction, since Atlassian renders no inline-code styling on headings —
2251/// rather than building a document the validator only rejects at write time
2252/// (issue #1005). Recurses into `content` to be robust to future inline
2253/// containers; today heading inline content is flat text nodes.
2254fn strip_heading_code_marks(nodes: &mut [AdfNode]) -> usize {
2255    let mut removed = 0;
2256    for node in nodes.iter_mut() {
2257        if let Some(marks) = node.marks.as_mut() {
2258            let before = marks.len();
2259            marks.retain(|m| m.mark_type != "code");
2260            removed += before - marks.len();
2261            if marks.is_empty() {
2262                node.marks = None;
2263            }
2264        }
2265        if let Some(children) = node.content.as_mut() {
2266            removed += strip_heading_code_marks(children);
2267        }
2268    }
2269    removed
2270}
2271
2272/// Prepends a mark before existing marks to preserve outside-in ordering.
2273fn prepend_mark(node: &mut AdfNode, mark: AdfMark) {
2274    if let Some(ref mut marks) = node.marks {
2275        marks.insert(0, mark);
2276    } else {
2277        node.marks = Some(vec![mark]);
2278    }
2279}
2280
2281/// Returns `true` when an underscore delimiter run of `len` bytes starting at
2282/// byte position `delim_pos` in `text` is flanked by alphanumeric characters on
2283/// **both** sides — meaning it sits inside a word and must NOT open or close an
2284/// emphasis span per CommonMark.
2285fn is_intraword_underscore(text: &str, delim_pos: usize, len: usize) -> bool {
2286    let before = text[..delim_pos]
2287        .chars()
2288        .next_back()
2289        .is_some_and(char::is_alphanumeric);
2290    let after = text[delim_pos + len..]
2291        .chars()
2292        .next()
2293        .is_some_and(char::is_alphanumeric);
2294    before && after
2295}
2296
2297/// Finds the first occurrence of `needle` in `haystack`, skipping over
2298/// backslash-escaped characters (e.g. `\*` is not matched when searching
2299/// for `*`).
2300fn find_unescaped(haystack: &str, needle: &str) -> Option<usize> {
2301    let needle_bytes = needle.as_bytes();
2302    let hay_bytes = haystack.as_bytes();
2303    let mut i = 0;
2304    while i < hay_bytes.len() {
2305        if hay_bytes[i] == b'\\' {
2306            i += 2; // skip escaped character
2307            continue;
2308        }
2309        if hay_bytes[i..].starts_with(needle_bytes) {
2310            return Some(i);
2311        }
2312        i += 1;
2313    }
2314    None
2315}
2316
2317/// Finds the first occurrence of a single byte `ch` in `haystack`, skipping
2318/// over backslash-escaped characters.
2319fn find_unescaped_char(haystack: &str, ch: u8) -> Option<usize> {
2320    let hay_bytes = haystack.as_bytes();
2321    let mut i = 0;
2322    while i < hay_bytes.len() {
2323        if hay_bytes[i] == b'\\' {
2324            i += 2;
2325            continue;
2326        }
2327        if hay_bytes[i] == ch {
2328            return Some(i);
2329        }
2330        i += 1;
2331    }
2332    None
2333}
2334
2335/// Tries to parse ***bold+italic***, **bold**, *italic* (or underscore variants) starting at position `i`.
2336/// Returns (end_position, inner_content, is_bold).
2337///
2338/// The triple-delimiter case (`***` / `___`) is checked first so that `***text***` is parsed as
2339/// bold wrapping italic content, rather than having the `**` branch consume the wrong closing
2340/// delimiter and leave stray `*` characters in the text (see issue #401).
2341///
2342/// For underscore delimiters, intraword positions are rejected per CommonMark: a `_` flanked
2343/// by alphanumeric characters on both sides must not open or close emphasis (see issue #438).
2344fn try_parse_emphasis(text: &str, i: usize) -> Option<(usize, &str, bool)> {
2345    let rest = &text[i..];
2346
2347    // Bold+italic: *** or ___
2348    // Parse as bold wrapping italic: the inner content will be recursively parsed and pick up
2349    // the inner * / _ as an em mark.
2350    if rest.starts_with("***") || rest.starts_with("___") {
2351        let is_underscore = rest.starts_with("___");
2352        if is_underscore && is_intraword_underscore(text, i, 3) {
2353            return None;
2354        }
2355        let triple = &rest[..3];
2356        let after = &rest[3..];
2357        if let Some(close) = find_unescaped(after, triple) {
2358            if close > 0 {
2359                let close_pos = i + 3 + close;
2360                if is_underscore && is_intraword_underscore(text, close_pos, 3) {
2361                    return None;
2362                }
2363                // Return a slice that includes the inner italic delimiters from the
2364                // original text: for `***text***`, return `*text*`.  The recursive
2365                // parse_inline call will then pick up the inner `*…*` as an em mark.
2366                let content = &rest[2..=3 + close];
2367                let end = i + 3 + close + 3;
2368                return Some((end, content, true));
2369            }
2370        }
2371    }
2372
2373    // Bold: ** or __
2374    if rest.starts_with("**") || rest.starts_with("__") {
2375        let is_underscore = rest.starts_with("__");
2376        if is_underscore && is_intraword_underscore(text, i, 2) {
2377            return None;
2378        }
2379        let delimiter = &rest[..2];
2380        let after = &rest[2..];
2381        let close = find_unescaped(after, delimiter)?;
2382        if close == 0 {
2383            return None;
2384        }
2385        let close_pos = i + 2 + close;
2386        if is_underscore && is_intraword_underscore(text, close_pos, 2) {
2387            return None;
2388        }
2389        let content = &after[..close];
2390        let end = i + 2 + close + 2;
2391        return Some((end, content, true));
2392    }
2393
2394    // Italic: * or _
2395    if rest.starts_with('*') || rest.starts_with('_') {
2396        let delim_char = rest.as_bytes()[0];
2397        let is_underscore = delim_char == b'_';
2398        if is_underscore && is_intraword_underscore(text, i, 1) {
2399            return None;
2400        }
2401        let after = &rest[1..];
2402        let close = find_unescaped_char(after, delim_char)?;
2403        if close == 0 {
2404            return None;
2405        }
2406        let close_pos = i + 1 + close;
2407        if is_underscore && is_intraword_underscore(text, close_pos, 1) {
2408            return None;
2409        }
2410        let content = &after[..close];
2411        let end = i + 1 + close + 1;
2412        return Some((end, content, false));
2413    }
2414
2415    None
2416}
2417
2418/// Tries to parse ~~strikethrough~~ starting at position `i`.
2419fn try_parse_strikethrough(text: &str, i: usize) -> Option<(usize, &str)> {
2420    let rest = &text[i..];
2421    if !rest.starts_with("~~") {
2422        return None;
2423    }
2424    let after = &rest[2..];
2425    let close = after.find("~~")?;
2426    if close == 0 {
2427        return None;
2428    }
2429    let content = &after[..close];
2430    Some((i + 2 + close + 2, content))
2431}
2432
2433/// Tries to parse a CommonMark inline code span starting at position `i`.
2434///
2435/// Supports multi-backtick delimiters: the opening run of N backticks must
2436/// be matched by a closing run of exactly N backticks.  If both ends of the
2437/// enclosed content begin and end with a space and the content is not all
2438/// spaces, one space is stripped from each side per the CommonMark spec.
2439fn try_parse_inline_code(text: &str, i: usize) -> Option<(usize, &str)> {
2440    let rest = &text[i..];
2441    let bytes = rest.as_bytes();
2442    if bytes.is_empty() || bytes[0] != b'`' {
2443        return None;
2444    }
2445    let mut opening = 0usize;
2446    while opening < bytes.len() && bytes[opening] == b'`' {
2447        opening += 1;
2448    }
2449
2450    let mut j = opening;
2451    while j < bytes.len() {
2452        if bytes[j] == b'`' {
2453            let run_start = j;
2454            while j < bytes.len() && bytes[j] == b'`' {
2455                j += 1;
2456            }
2457            if j - run_start == opening {
2458                let content = &rest[opening..run_start];
2459                let content = strip_code_span_padding(content);
2460                return Some((i + j, content));
2461            }
2462        } else {
2463            j += 1;
2464        }
2465    }
2466    None
2467}
2468
2469/// Implements the CommonMark code-span space-stripping rule: if the content
2470/// both begins and ends with a space character and is not composed entirely
2471/// of spaces, one space character is removed from each side.
2472fn strip_code_span_padding(content: &str) -> &str {
2473    let bytes = content.as_bytes();
2474    if bytes.len() >= 2
2475        && bytes[0] == b' '
2476        && bytes[bytes.len() - 1] == b' '
2477        && content.bytes().any(|b| b != b' ')
2478    {
2479        &content[1..content.len() - 1]
2480    } else {
2481        content
2482    }
2483}
2484
2485/// Tries to parse a bracketed span `[text]{attrs}` starting at position `i`.
2486/// Used for `[text]{underline}` and similar constructs.  `depth` is the
2487/// caller's inline recursion depth (see [`parse_inline_impl`]).
2488fn try_parse_bracketed_span(text: &str, i: usize, depth: usize) -> Option<(usize, Vec<AdfNode>)> {
2489    let rest = &text[i..];
2490    if !rest.starts_with('[') {
2491        return None;
2492    }
2493
2494    // Find the matching ] by counting bracket depth (supports nested brackets
2495    // such as [[text](url)]{underline} for underline-before-link ordering).
2496    // Backslash-escaped brackets are skipped (issue #493).
2497    let mut bracket_depth: usize = 0;
2498    let mut bracket_close = None;
2499    let bs_bytes = rest.as_bytes();
2500    for (j, ch) in rest.char_indices() {
2501        match ch {
2502            '\\' if j + 1 < bs_bytes.len()
2503                && (bs_bytes[j + 1] == b'[' || bs_bytes[j + 1] == b']') => {}
2504            '[' if j == 0 || bs_bytes[j - 1] != b'\\' => bracket_depth += 1,
2505            ']' if j == 0 || bs_bytes[j - 1] != b'\\' => {
2506                bracket_depth -= 1;
2507                if bracket_depth == 0 {
2508                    bracket_close = Some(j);
2509                    break;
2510                }
2511            }
2512            _ => {}
2513        }
2514    }
2515    let bracket_close = bracket_close?;
2516    // Make sure this isn't a link: next char after ] must be { not (
2517    let after_bracket = &rest[bracket_close + 1..];
2518    if !after_bracket.starts_with('{') {
2519        return None;
2520    }
2521
2522    let span_text = &rest[1..bracket_close];
2523    let attrs_start = i + bracket_close + 1;
2524    let (attrs_end, attrs) = parse_attrs(text, attrs_start)?;
2525
2526    let mut marks = Vec::new();
2527    if attrs.has_flag("underline") {
2528        marks.push(AdfMark::underline());
2529    }
2530    let ann_ids = attrs.get_all("annotation-id");
2531    let ann_types = attrs.get_all("annotation-type");
2532    for (idx, ann_id) in ann_ids.iter().enumerate() {
2533        let ann_type = ann_types.get(idx).copied().unwrap_or("inlineComment");
2534        marks.push(AdfMark::annotation(ann_id, ann_type));
2535    }
2536
2537    if marks.is_empty() {
2538        return None; // no recognized marks
2539    }
2540
2541    let inner = parse_inline_impl(span_text, false, depth + 1);
2542    let result: Vec<AdfNode> = inner
2543        .into_iter()
2544        .map(|mut node| {
2545            // Prepend bracket marks before inner marks to preserve original
2546            // ADF mark ordering (e.g., [underline, strong] not [strong, underline]).
2547            let mut combined = marks.clone();
2548            if let Some(ref existing) = node.marks {
2549                combined.extend(existing.iter().cloned());
2550            }
2551            node.marks = Some(combined);
2552            node
2553        })
2554        .collect();
2555
2556    Some((attrs_end, result))
2557}
2558
2559/// Dispatches an inline directive to the appropriate ADF node constructor.
2560/// Returns `(AdfNode, end_pos)` on success.  `depth` is the caller's inline
2561/// recursion depth (see [`parse_inline_impl`]).
2562fn try_dispatch_inline_directive(text: &str, pos: usize, depth: usize) -> Option<(AdfNode, usize)> {
2563    let d = try_parse_inline_directive(text, pos)?;
2564    let content = d.content.as_deref().unwrap_or("");
2565
2566    let node = match d.name.as_str() {
2567        "card" => {
2568            // Prefer the `url` attribute when present; fall back to the
2569            // bracketed content.  The attribute form is used when the URL
2570            // contains characters (such as `]` or `\n`) that would otherwise
2571            // break `:card[URL]` parsing.
2572            let url = d
2573                .attrs
2574                .as_ref()
2575                .and_then(|a| a.get("url"))
2576                .unwrap_or(content);
2577            let mut node = AdfNode::inline_card(url);
2578            pass_through_local_id(&d.attrs, &mut node);
2579            node
2580        }
2581        "status" => {
2582            let color = d
2583                .attrs
2584                .as_ref()
2585                .and_then(|a| a.get("color"))
2586                .unwrap_or("neutral");
2587            let mut node = AdfNode::status(content, color);
2588            // Pass through style and localId if present
2589            if let Some(ref attrs) = d.attrs {
2590                if let Some(ref mut node_attrs) = node.attrs {
2591                    if let Some(style) = attrs.get("style") {
2592                        node_attrs["style"] = serde_json::Value::String(style.to_string());
2593                    }
2594                    if let Some(local_id) = attrs.get("localId") {
2595                        node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
2596                    }
2597                }
2598            }
2599            node
2600        }
2601        "date" => {
2602            let timestamp = d
2603                .attrs
2604                .as_ref()
2605                .and_then(|a| a.get("timestamp"))
2606                .map_or_else(|| iso_date_to_epoch_ms(content), ToString::to_string);
2607            let mut node = AdfNode::date(&timestamp);
2608            pass_through_local_id(&d.attrs, &mut node);
2609            node
2610        }
2611        "mention" => {
2612            let id = d.attrs.as_ref().and_then(|a| a.get("id")).unwrap_or("");
2613            let mut node = AdfNode::mention(id, content);
2614            // Pass through optional userType and accessLevel
2615            if let Some(ref attrs) = d.attrs {
2616                if let (Some(ref mut node_attrs), true) = (
2617                    &mut node.attrs,
2618                    attrs.get("userType").is_some() || attrs.get("accessLevel").is_some(),
2619                ) {
2620                    if let Some(ut) = attrs.get("userType") {
2621                        node_attrs["userType"] = serde_json::Value::String(ut.to_string());
2622                    }
2623                    if let Some(al) = attrs.get("accessLevel") {
2624                        node_attrs["accessLevel"] = serde_json::Value::String(al.to_string());
2625                    }
2626                }
2627            }
2628            pass_through_local_id(&d.attrs, &mut node);
2629            node
2630        }
2631        "span" => {
2632            let mut marks = Vec::new();
2633            if let Some(ref attrs) = d.attrs {
2634                if let Some(color) = attrs.get("color") {
2635                    marks.push(AdfMark::text_color(color));
2636                }
2637                if let Some(bg) = attrs.get("bg") {
2638                    marks.push(AdfMark::background_color(bg));
2639                }
2640                if attrs.has_flag("sub") {
2641                    marks.push(AdfMark::subsup("sub"));
2642                }
2643                if attrs.has_flag("sup") {
2644                    marks.push(AdfMark::subsup("sup"));
2645                }
2646            }
2647            if marks.is_empty() {
2648                AdfNode::text(content)
2649            } else {
2650                // Parse inner content to handle nested syntax (e.g., links).
2651                // Prepend span marks before inner marks to preserve ordering.
2652                let inner = parse_inline_impl(content, false, depth + 1);
2653                let mut nodes: Vec<AdfNode> = inner
2654                    .into_iter()
2655                    .map(|mut node| {
2656                        let mut combined = marks.clone();
2657                        if let Some(ref existing) = node.marks {
2658                            combined.extend(existing.iter().cloned());
2659                        }
2660                        node.marks = Some(combined);
2661                        node
2662                    })
2663                    .collect();
2664                // Return the first marked node (typical case is a single node).
2665                nodes.remove(0)
2666            }
2667        }
2668        "placeholder" => AdfNode::placeholder(content),
2669        "media-inline" => {
2670            let mut json_attrs = serde_json::Map::new();
2671            if let Some(ref attrs) = d.attrs {
2672                for key in &["type", "id", "collection", "url", "alt", "width", "height"] {
2673                    if let Some(val) = attrs.get(key) {
2674                        if *key == "width" || *key == "height" {
2675                            if let Ok(n) = val.parse::<u64>() {
2676                                json_attrs.insert(
2677                                    (*key).to_string(),
2678                                    serde_json::Value::Number(n.into()),
2679                                );
2680                                continue;
2681                            }
2682                        }
2683                        json_attrs.insert(
2684                            (*key).to_string(),
2685                            serde_json::Value::String(val.to_string()),
2686                        );
2687                    }
2688                }
2689                if let Some(local_id) = attrs.get("localId") {
2690                    json_attrs.insert(
2691                        "localId".to_string(),
2692                        serde_json::Value::String(local_id.to_string()),
2693                    );
2694                }
2695            }
2696            AdfNode::media_inline(serde_json::Value::Object(json_attrs))
2697        }
2698        "extension" => {
2699            let ext_type = d.attrs.as_ref().and_then(|a| a.get("type")).unwrap_or("");
2700            let ext_key = d.attrs.as_ref().and_then(|a| a.get("key")).unwrap_or("");
2701            AdfNode::inline_extension(ext_type, ext_key, Some(content))
2702        }
2703        _ => return None, // unknown directive — fall through to plain text
2704    };
2705
2706    Some((node, d.end_pos))
2707}
2708
2709/// Tries to parse a bare URL (`http://` or `https://`) starting at position `i`.
2710/// Scans forward until whitespace, `)`, `]`, or end of string.
2711fn try_parse_bare_url(text: &str, i: usize) -> Option<(usize, &str)> {
2712    let rest = &text[i..];
2713    if !rest.starts_with("http://") && !rest.starts_with("https://") {
2714        return None;
2715    }
2716    // URL extends to the next whitespace or delimiter
2717    let end = rest
2718        .find(|c: char| c.is_whitespace() || c == ')' || c == ']' || c == '>')
2719        .unwrap_or(rest.len());
2720    // Strip trailing punctuation that's likely not part of the URL
2721    let url = rest[..end].trim_end_matches(['.', ',', ';', '!', '?']);
2722    if url.len() <= "https://".len() {
2723        return None; // too short to be a real URL
2724    }
2725    Some((i + url.len(), url))
2726}
2727
2728/// Tries to parse an emoji shortcode `:name:` starting at position `i`.
2729/// The name must match `[a-zA-Z0-9_+-]+`.
2730fn try_parse_emoji_shortcode(text: &str, i: usize) -> Option<(usize, &str)> {
2731    let rest = &text[i..];
2732    if !rest.starts_with(':') {
2733        return None;
2734    }
2735    let after = &rest[1..];
2736    let name_end =
2737        after.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '+' && c != '-')?;
2738    if name_end == 0 {
2739        return None;
2740    }
2741    if after.as_bytes().get(name_end) != Some(&b':') {
2742        return None;
2743    }
2744    let name = &after[..name_end];
2745    Some((i + 1 + name_end + 1, name))
2746}
2747
2748/// Parses an emoji shortcode that has already been matched, then checks for
2749/// trailing `{id="..." text="..."}` attributes to preserve round-trip fidelity.
2750fn parse_emoji_with_attrs(text: &str, shortcode_end: usize, short_name: &str) -> (usize, AdfNode) {
2751    // Issue #576: An emoji with a combined shortName like `:slightly_smiling_face::bow:`
2752    // is emitted as `:slightly_smiling_face::bow:{shortName="..." ...}`. Extend the
2753    // match through any adjacent `:name:` shortcodes so that a trailing directive
2754    // attaches to the whole chain as a single emoji, using the directive's shortName.
2755    let mut chain_end = shortcode_end;
2756    while let Some((next_end, _)) = try_parse_emoji_shortcode(text, chain_end) {
2757        chain_end = next_end;
2758    }
2759    if chain_end > shortcode_end {
2760        if let Some((attr_end, attrs)) = parse_attrs(text, chain_end) {
2761            return (attr_end, build_emoji_node(&attrs, short_name));
2762        }
2763    }
2764
2765    if let Some((attr_end, attrs)) = parse_attrs(text, shortcode_end) {
2766        (attr_end, build_emoji_node(&attrs, short_name))
2767    } else {
2768        (shortcode_end, AdfNode::emoji(&format!(":{short_name}:")))
2769    }
2770}
2771
2772/// Builds an emoji `AdfNode` from parsed directive attrs, falling back to
2773/// the matched shortcode name when `shortName` is absent from the directive.
2774fn build_emoji_node(attrs: &Attrs, short_name: &str) -> AdfNode {
2775    let resolved_name = attrs
2776        .get("shortName")
2777        .map_or_else(|| format!(":{short_name}:"), str::to_string);
2778    let mut emoji_attrs = serde_json::json!({ "shortName": resolved_name });
2779    if let Some(id) = attrs.get("id") {
2780        emoji_attrs["id"] = serde_json::Value::String(id.to_string());
2781    }
2782    if let Some(t) = attrs.get("text") {
2783        emoji_attrs["text"] = serde_json::Value::String(t.to_string());
2784    }
2785    if let Some(lid) = attrs.get("localId") {
2786        emoji_attrs["localId"] = serde_json::Value::String(lid.to_string());
2787    }
2788    AdfNode {
2789        node_type: "emoji".to_string(),
2790        attrs: Some(emoji_attrs),
2791        content: None,
2792        text: None,
2793        marks: None,
2794        local_id: None,
2795        parameters: None,
2796    }
2797}
2798
2799/// Finds the closing `)` that matches the opening `(` at position `open`,
2800/// counting nested parentheses so that URLs containing `(` and `)` are
2801/// handled correctly.  Returns the index of the matching `)` relative to
2802/// the start of `s`, or `None` if no match is found.
2803fn find_closing_paren(s: &str, open: usize) -> Option<usize> {
2804    let mut depth: usize = 0;
2805    for (j, ch) in s[open..].char_indices() {
2806        match ch {
2807            '(' => depth += 1,
2808            ')' => {
2809                depth -= 1;
2810                if depth == 0 {
2811                    return Some(open + j);
2812                }
2813            }
2814            _ => {}
2815        }
2816    }
2817    None
2818}
2819
2820/// Tries to parse [text](url) starting at position `i`.
2821///
2822/// Uses bracket depth counting to find the matching `]`, so that `[` characters
2823/// inside the text (e.g. `[Task] some text ([Link](url))`) don't cause a false
2824/// match on an earlier `](`.
2825fn try_parse_link(text: &str, i: usize) -> Option<(usize, &str, &str)> {
2826    let rest = &text[i..];
2827    if !rest.starts_with('[') {
2828        return None;
2829    }
2830
2831    // Find the matching ] by counting bracket depth, skipping escaped brackets
2832    let mut depth: usize = 0;
2833    let mut text_end = None;
2834    let bytes = rest.as_bytes();
2835    for (j, ch) in rest.char_indices() {
2836        match ch {
2837            '\\' if j + 1 < bytes.len() && (bytes[j + 1] == b'[' || bytes[j + 1] == b']') => {
2838                // Skip backslash-escaped bracket (issue #493)
2839            }
2840            '[' if j == 0 || bytes[j - 1] != b'\\' => depth += 1,
2841            ']' if j == 0 || bytes[j - 1] != b'\\' => {
2842                depth -= 1;
2843                if depth == 0 {
2844                    text_end = Some(j);
2845                    break;
2846                }
2847            }
2848            _ => {}
2849        }
2850    }
2851
2852    let text_end = text_end?;
2853    let link_text = &rest[1..text_end];
2854    // Must be immediately followed by (
2855    let after_bracket = &rest[text_end + 1..];
2856    if !after_bracket.starts_with('(') {
2857        return None;
2858    }
2859    let url_start = text_end + 1; // index of the '('
2860    let url_end = find_closing_paren(rest, url_start)?;
2861    let href = &rest[url_start + 1..url_end];
2862
2863    Some((i + url_end + 1, link_text, href))
2864}
2865
2866// ── ADF → Markdown ──────────────────────────────────────────────────
2867
2868/// Options for ADF-to-markdown rendering.
2869#[derive(Debug, Clone, Default)]
2870pub struct RenderOptions {
2871    /// When true, omit `localId` attributes from directive output.
2872    pub strip_local_ids: bool,
2873}
2874
2875/// Converts an ADF document to a markdown string.
2876pub fn adf_to_markdown(doc: &AdfDocument) -> Result<String> {
2877    adf_to_markdown_with_options(doc, &RenderOptions::default())
2878}
2879
2880/// Converts an ADF document to a markdown string with options.
2881pub fn adf_to_markdown_with_options(doc: &AdfDocument, opts: &RenderOptions) -> Result<String> {
2882    let mut output = String::new();
2883
2884    for (i, node) in doc.content.iter().enumerate() {
2885        if i > 0 {
2886            output.push('\n');
2887        }
2888        render_block_node(node, &mut output, opts);
2889    }
2890
2891    Ok(output)
2892}
2893
2894/// Flattens an ADF document to plain text by concatenating all `text` nodes.
2895///
2896/// Block boundaries (paragraph, heading, list item, table cell, etc.) are
2897/// separated by a single space so a multi-paragraph anchor selection matches
2898/// when the user copies the text without trailing/leading whitespace.
2899///
2900/// Used by [`crate::atlassian::confluence_api::ConfluenceApi::resolve_anchor`]
2901/// to count occurrences of an inline-comment anchor on the live page body.
2902#[must_use]
2903pub fn adf_to_plain_text(doc: &AdfDocument) -> String {
2904    let mut out = String::new();
2905    for node in &doc.content {
2906        collect_plain_text(node, &mut out);
2907        if !out.is_empty() && !out.ends_with(' ') {
2908            out.push(' ');
2909        }
2910    }
2911    out.truncate(out.trim_end().len());
2912    out
2913}
2914
2915fn collect_plain_text(node: &AdfNode, out: &mut String) {
2916    if let Some(text) = &node.text {
2917        out.push_str(text);
2918    }
2919    if let Some(children) = &node.content {
2920        for child in children {
2921            collect_plain_text(child, out);
2922        }
2923    }
2924}
2925
2926/// Pushes a `localId=<value>` entry to an attribute parts vec,
2927/// unless `opts.strip_local_ids` is set or the value is a placeholder.
2928/// Copies `localId` from parsed directive attrs to an ADF node's attrs if present.
2929fn pass_through_local_id(dir_attrs: &Option<crate::atlassian::attrs::Attrs>, node: &mut AdfNode) {
2930    if let Some(ref attrs) = dir_attrs {
2931        if let Some(local_id) = attrs.get("localId") {
2932            if let Some(ref mut node_attrs) = node.attrs {
2933                node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
2934            } else {
2935                node.attrs = Some(serde_json::json!({"localId": local_id}));
2936            }
2937        }
2938    }
2939}
2940
2941/// Copies `localId` from directive attrs to the node's top-level `local_id` field,
2942/// and parses `params` JSON from directive attrs into the node's `parameters` field.
2943fn pass_through_expand_params(
2944    dir_attrs: &Option<crate::atlassian::attrs::Attrs>,
2945    node: &mut AdfNode,
2946) {
2947    if let Some(ref attrs) = dir_attrs {
2948        if let Some(local_id) = attrs.get("localId") {
2949            node.local_id = Some(local_id.to_string());
2950        }
2951        if let Some(params_str) = attrs.get("params") {
2952            if let Ok(params) = serde_json::from_str(params_str) {
2953                node.parameters = Some(params);
2954            }
2955        }
2956    }
2957}
2958
2959// listItem localId is emitted as trailing inline attrs on the item line
2960// (e.g., `- item text {localId=...}`) and parsed back by extracting
2961// trailing attrs from the list item text. This avoids the block-attrs
2962// promotion issue where {localId=...} on a separate line would be
2963// applied to the parent list node.
2964
2965/// Extracts trailing `{localId=... paraLocalId=...}` from list item text.
2966/// Returns the text without the trailing attrs, the listItem localId, and
2967/// the paragraph localId if found.
2968fn extract_trailing_local_id(text: &str) -> (&str, Option<String>, Option<String>) {
2969    let trimmed = text.trim_end();
2970    if !trimmed.ends_with('}') {
2971        return (text, None, None);
2972    }
2973    // Find the opening brace.  Only match a standalone `{…}` block that is
2974    // preceded by whitespace (or is at the start of the string).  A `{` that
2975    // immediately follows `]` is part of an inline directive (e.g.
2976    // `:mention[text]{id=… localId=…}`) and must NOT be consumed here.
2977    if let Some(brace_pos) = trimmed.rfind('{') {
2978        if brace_pos > 0 && !trimmed.as_bytes()[brace_pos - 1].is_ascii_whitespace() {
2979            return (text, None, None);
2980        }
2981        let attr_str = &trimmed[brace_pos..];
2982        if let Some((_, attrs)) = parse_attrs(attr_str, 0) {
2983            let local_id = attrs.get("localId").map(str::to_string);
2984            let para_local_id = attrs.get("paraLocalId").map(str::to_string);
2985            if local_id.is_some() || para_local_id.is_some() {
2986                let before = trimmed[..brace_pos]
2987                    .strip_suffix(' ')
2988                    .unwrap_or(&trimmed[..brace_pos]);
2989                return (before, local_id, para_local_id);
2990            }
2991        }
2992    }
2993    (text, None, None)
2994}
2995
2996/// Creates a `listItem` node, optionally with a `localId` attribute
2997/// and a `paraLocalId` on its first paragraph child.
2998/// Parses the first line of a list item and any indented sub-content into
2999/// an `AdfNode::list_item`.  When the first line is a code fence opener
3000/// (`` ``` ``), the line is folded into the sub-content so the block-level
3001/// code fence parser handles it correctly (issue #511).
3002///
3003/// `depth` is the nesting depth for parsers spawned on the sub-content —
3004/// callers pass their own depth plus one.
3005fn parse_list_item_first_line(
3006    item_text: &str,
3007    sub_lines: Vec<String>,
3008    local_id: Option<String>,
3009    para_local_id: Option<String>,
3010    depth: usize,
3011) -> Result<AdfNode> {
3012    if item_text.starts_with("```") {
3013        // Treat the code fence opener + indented body as block content.
3014        let mut all_lines = vec![item_text.to_string()];
3015        all_lines.extend(sub_lines);
3016        let combined = all_lines.join("\n");
3017        let nested = MarkdownParser::with_depth(&combined, depth).parse_blocks()?;
3018        Ok(list_item_with_local_id(nested, local_id, para_local_id))
3019    } else if let Some(media) = try_parse_media_single_from_line(item_text) {
3020        // Block-level image (issue #430).
3021        if sub_lines.is_empty() {
3022            Ok(list_item_with_local_id(
3023                vec![media],
3024                local_id,
3025                para_local_id,
3026            ))
3027        } else {
3028            let sub_text = sub_lines.join("\n");
3029            let mut nested = MarkdownParser::with_depth(&sub_text, depth).parse_blocks()?;
3030            let mut content = vec![media];
3031            content.append(&mut nested);
3032            Ok(list_item_with_local_id(content, local_id, para_local_id))
3033        }
3034    } else {
3035        let first_node = AdfNode::paragraph(parse_inline(item_text));
3036        if sub_lines.is_empty() {
3037            Ok(list_item_with_local_id(
3038                vec![first_node],
3039                local_id,
3040                para_local_id,
3041            ))
3042        } else {
3043            let sub_text = sub_lines.join("\n");
3044            let mut nested = MarkdownParser::with_depth(&sub_text, depth).parse_blocks()?;
3045            let mut content = vec![first_node];
3046            content.append(&mut nested);
3047            Ok(list_item_with_local_id(content, local_id, para_local_id))
3048        }
3049    }
3050}
3051
3052fn list_item_with_local_id(
3053    mut content: Vec<AdfNode>,
3054    local_id: Option<String>,
3055    para_local_id: Option<String>,
3056) -> AdfNode {
3057    if let Some(id) = &para_local_id {
3058        if let Some(first) = content.first_mut() {
3059            if first.node_type == "paragraph" {
3060                let node_attrs = first.attrs.get_or_insert_with(|| serde_json::json!({}));
3061                node_attrs["localId"] = serde_json::Value::String(id.clone());
3062            }
3063        }
3064    }
3065    let mut item = AdfNode::list_item(content);
3066    if let Some(id) = local_id {
3067        item.attrs = Some(serde_json::json!({"localId": id}));
3068    }
3069    item
3070}
3071
3072fn maybe_push_local_id(attrs: &serde_json::Value, parts: &mut Vec<String>, opts: &RenderOptions) {
3073    if opts.strip_local_ids {
3074        return;
3075    }
3076    if let Some(local_id) = attrs.get("localId").and_then(serde_json::Value::as_str) {
3077        if !local_id.is_empty() && local_id != "00000000-0000-0000-0000-000000000000" {
3078            parts.push(format_kv("localId", local_id));
3079        }
3080    }
3081}
3082
3083/// Renders a sequence of block nodes with blank-line separators between them.
3084fn render_block_children(children: &[AdfNode], output: &mut String, opts: &RenderOptions) {
3085    for (i, child) in children.iter().enumerate() {
3086        if i > 0 {
3087            output.push('\n');
3088        }
3089        render_block_node(child, output, opts);
3090    }
3091}
3092
3093/// Formats a float as an integer string when it has no fractional part,
3094/// otherwise as a regular float string.
3095fn fmt_f64_attr(v: f64) -> String {
3096    if v.fract() == 0.0 {
3097        format!("{}", v as i64)
3098    } else {
3099        v.to_string()
3100    }
3101}
3102
3103/// Parses a numeric attribute value string into a JSON number value that
3104/// preserves the original integer/float distinction. Returns `None` if the
3105/// string cannot be parsed as a number.
3106///
3107/// Strings without a `.` or exponent are parsed as integers (so `"800"` stays
3108/// `800`, not `800.0`); strings with a decimal point are parsed as floats.
3109fn parse_numeric_attr(s: &str) -> Option<serde_json::Value> {
3110    if s.contains('.') || s.contains('e') || s.contains('E') {
3111        s.parse::<f64>().ok().map(serde_json::Value::from)
3112    } else {
3113        s.parse::<i64>().ok().map(serde_json::Value::from)
3114    }
3115}
3116
3117/// Formats a JSON numeric value as a markdown attribute string, preserving
3118/// whether the source was stored as an integer or a float.
3119///
3120/// Returns `None` if `v` is not a number. Integer values emit as `800`;
3121/// floating-point values emit as `800.0` (or `66.66` for non-integer floats),
3122/// so that a subsequent [`parse_numeric_attr`] round-trip recovers the same
3123/// JSON type.
3124fn fmt_numeric_attr(v: &serde_json::Value) -> Option<String> {
3125    if let Some(n) = v.as_i64() {
3126        return Some(n.to_string());
3127    }
3128    if let Some(n) = v.as_u64() {
3129        return Some(n.to_string());
3130    }
3131    if let Some(n) = v.as_f64() {
3132        if n.fract() == 0.0 && n.is_finite() {
3133            return Some(format!("{n:.1}"));
3134        }
3135        return Some(n.to_string());
3136    }
3137    None
3138}
3139
3140/// Renders a block-level ADF node to markdown.
3141fn render_block_node(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
3142    match node.node_type.as_str() {
3143        "paragraph" => {
3144            let is_empty = node.content.as_ref().map_or(true, Vec::is_empty);
3145            // Build directive attr string for localId when using ::paragraph form
3146            let dir_attrs = {
3147                let mut parts = Vec::new();
3148                if let Some(ref attrs) = node.attrs {
3149                    maybe_push_local_id(attrs, &mut parts, opts);
3150                }
3151                if parts.is_empty() {
3152                    String::new()
3153                } else {
3154                    format!("{{{}}}", parts.join(" "))
3155                }
3156            };
3157            if is_empty {
3158                output.push_str(&format!("::paragraph{dir_attrs}\n"));
3159            } else {
3160                // Render to a buffer first to check if content is whitespace-only
3161                let mut buf = String::new();
3162                render_inline_content(node, &mut buf, opts);
3163                if buf.trim().is_empty() && !buf.is_empty() {
3164                    // Whitespace-only content (e.g. NBSP) would be lost as a plain
3165                    // line — use the ::paragraph[content]{attrs} directive form
3166                    output.push_str(&format!("::paragraph[{buf}]{dir_attrs}\n"));
3167                } else {
3168                    // Escape a leading list-marker pattern so paragraph
3169                    // text is not re-parsed as a list item (issue #402).
3170                    // Indent continuation lines produced by hardBreaks so
3171                    // they are not re-parsed as list items (issue #455).
3172                    let mut is_first_line = true;
3173                    for line in buf.split('\n') {
3174                        if is_first_line {
3175                            if is_list_start(line) {
3176                                output.push_str(&escape_list_marker(line));
3177                            } else {
3178                                output.push_str(line);
3179                            }
3180                            is_first_line = false;
3181                        } else {
3182                            output.push('\n');
3183                            if !line.is_empty() {
3184                                output.push_str("  ");
3185                            }
3186                            output.push_str(line);
3187                        }
3188                    }
3189                    output.push('\n');
3190                }
3191            }
3192        }
3193        "heading" => {
3194            let level = node
3195                .attrs
3196                .as_ref()
3197                .and_then(|a| a.get("level"))
3198                .and_then(serde_json::Value::as_u64)
3199                .unwrap_or(1);
3200            for _ in 0..level {
3201                output.push('#');
3202            }
3203            output.push(' ');
3204            let mut buf = String::new();
3205            render_inline_content(node, &mut buf, opts);
3206            // Indent continuation lines produced by hardBreaks so they stay
3207            // within the heading when re-parsed (issue #433).
3208            let mut is_first_line = true;
3209            for line in buf.split('\n') {
3210                if is_first_line {
3211                    output.push_str(line);
3212                    is_first_line = false;
3213                } else {
3214                    output.push('\n');
3215                    if !line.is_empty() {
3216                        output.push_str("  ");
3217                    }
3218                    output.push_str(line);
3219                }
3220            }
3221            output.push('\n');
3222        }
3223        "codeBlock" => {
3224            let language_value = node.attrs.as_ref().and_then(|a| a.get("language"));
3225            let language = language_value
3226                .and_then(serde_json::Value::as_str)
3227                .unwrap_or("");
3228            output.push_str("```");
3229            if language.is_empty() && language_value.is_some() {
3230                // Explicit empty language attr: encode as ```"" to distinguish
3231                // from a codeBlock with no attrs at all (plain ```).
3232                output.push_str("\"\"");
3233            } else {
3234                output.push_str(language);
3235            }
3236            output.push('\n');
3237            if let Some(ref content) = node.content {
3238                for child in content {
3239                    if let Some(ref text) = child.text {
3240                        output.push_str(text);
3241                    }
3242                }
3243            }
3244            output.push_str("\n```\n");
3245        }
3246        "blockquote" => {
3247            if let Some(ref content) = node.content {
3248                for (i, child) in content.iter().enumerate() {
3249                    // Separate consecutive paragraph siblings with a blank
3250                    // blockquote-prefixed line so they re-parse as distinct
3251                    // paragraphs rather than being merged into one (issue #531).
3252                    if i > 0
3253                        && child.node_type == "paragraph"
3254                        && content[i - 1].node_type == "paragraph"
3255                    {
3256                        output.push_str(">\n");
3257                    }
3258                    let mut inner = String::new();
3259                    render_block_node(child, &mut inner, opts);
3260                    for line in inner.lines() {
3261                        output.push_str("> ");
3262                        output.push_str(line);
3263                        output.push('\n');
3264                    }
3265                }
3266            }
3267        }
3268        "bulletList" => {
3269            if let Some(ref items) = node.content {
3270                for item in items {
3271                    output.push_str("- ");
3272                    let content_start = output.len();
3273                    render_list_item_content(item, output, opts);
3274                    // If the rendered content begins with a sequence the
3275                    // bullet-list parser would interpret as a task checkbox
3276                    // marker, escape the leading `[` so the round-trip
3277                    // preserves this as a `bulletList` rather than promoting
3278                    // it to a `taskList` (issue #548).
3279                    if starts_with_task_marker(&output[content_start..]) {
3280                        output.insert(content_start, '\\');
3281                    }
3282                }
3283            }
3284        }
3285        "orderedList" => {
3286            let start = node
3287                .attrs
3288                .as_ref()
3289                .and_then(|a| a.get("order"))
3290                .and_then(serde_json::Value::as_u64)
3291                .unwrap_or(1);
3292            if let Some(ref items) = node.content {
3293                for (i, item) in items.iter().enumerate() {
3294                    let num = start + i as u64;
3295                    output.push_str(&format!("{num}. "));
3296                    render_list_item_content(item, output, opts);
3297                }
3298            }
3299        }
3300        "taskList" => {
3301            if let Some(ref items) = node.content {
3302                for item in items {
3303                    if item.node_type == "taskList" {
3304                        // A nested taskList is a sibling child of the outer
3305                        // taskList — render it indented so it round-trips back
3306                        // as a taskList, not a taskItem (issue #506).
3307                        let mut nested = String::new();
3308                        render_block_node(item, &mut nested, opts);
3309                        for line in nested.lines() {
3310                            output.push_str("  ");
3311                            output.push_str(line);
3312                            output.push('\n');
3313                        }
3314                    } else {
3315                        let state = item
3316                            .attrs
3317                            .as_ref()
3318                            .and_then(|a| a.get("state"))
3319                            .and_then(serde_json::Value::as_str)
3320                            .unwrap_or("TODO");
3321                        if state == "DONE" {
3322                            output.push_str("- [x] ");
3323                        } else {
3324                            output.push_str("- [ ] ");
3325                        }
3326                        render_list_item_content(item, output, opts);
3327                    }
3328                }
3329            }
3330        }
3331        "rule" => {
3332            output.push_str("---\n");
3333        }
3334        "table" => {
3335            render_table(node, output, opts);
3336        }
3337        "mediaSingle" => {
3338            if let Some(ref content) = node.content {
3339                for child in content {
3340                    if child.node_type == "media" {
3341                        render_media(child, node.attrs.as_ref(), output, opts);
3342                    }
3343                }
3344                for child in content {
3345                    if child.node_type == "caption" {
3346                        let mut cap_parts = Vec::new();
3347                        if let Some(ref attrs) = child.attrs {
3348                            maybe_push_local_id(attrs, &mut cap_parts, opts);
3349                        }
3350                        if cap_parts.is_empty() {
3351                            output.push_str(":::caption\n");
3352                        } else {
3353                            output.push_str(&format!(":::caption{{{}}}\n", cap_parts.join(" ")));
3354                        }
3355                        if let Some(ref caption_content) = child.content {
3356                            for inline in caption_content {
3357                                render_inline_node(inline, output, opts);
3358                            }
3359                            output.push('\n');
3360                        }
3361                        output.push_str(":::\n");
3362                    }
3363                }
3364            }
3365        }
3366        "blockCard" => {
3367            if let Some(ref attrs) = node.attrs {
3368                let url = attrs
3369                    .get("url")
3370                    .and_then(serde_json::Value::as_str)
3371                    .unwrap_or("");
3372                let mut attr_parts = Vec::new();
3373                if url_safe_in_bracket_content(url) {
3374                    output.push_str(&format!("::card[{url}]"));
3375                } else {
3376                    // URL would break `::card[URL]` parsing; use quoted attr form.
3377                    output.push_str("::card[]");
3378                    let escaped = url.replace('\\', "\\\\").replace('"', "\\\"");
3379                    attr_parts.push(format!("url=\"{escaped}\""));
3380                }
3381                if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3382                    attr_parts.push(format!("layout={layout}"));
3383                }
3384                if let Some(width) = attrs.get("width").and_then(serde_json::Value::as_u64) {
3385                    attr_parts.push(format!("width={width}"));
3386                }
3387                if !attr_parts.is_empty() {
3388                    output.push_str(&format!("{{{}}}", attr_parts.join(" ")));
3389                }
3390                output.push('\n');
3391            }
3392        }
3393        "embedCard" => {
3394            if let Some(ref attrs) = node.attrs {
3395                let url = attrs
3396                    .get("url")
3397                    .and_then(serde_json::Value::as_str)
3398                    .unwrap_or("");
3399                output.push_str(&format!("::embed[{url}]"));
3400                let mut attr_parts = Vec::new();
3401                if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3402                    attr_parts.push(format!("layout={layout}"));
3403                }
3404                if let Some(h) = attrs
3405                    .get("originalHeight")
3406                    .and_then(serde_json::Value::as_f64)
3407                {
3408                    attr_parts.push(format!("originalHeight={}", fmt_f64_attr(h)));
3409                }
3410                if let Some(w) = attrs.get("width").and_then(serde_json::Value::as_f64) {
3411                    attr_parts.push(format!("width={}", fmt_f64_attr(w)));
3412                }
3413                if !attr_parts.is_empty() {
3414                    output.push_str(&format!("{{{}}}", attr_parts.join(" ")));
3415                }
3416                output.push('\n');
3417            }
3418        }
3419        "extension" => {
3420            if let Some(ref attrs) = node.attrs {
3421                let ext_type = attrs
3422                    .get("extensionType")
3423                    .and_then(serde_json::Value::as_str)
3424                    .unwrap_or("");
3425                let ext_key = attrs
3426                    .get("extensionKey")
3427                    .and_then(serde_json::Value::as_str)
3428                    .unwrap_or("");
3429                let mut attr_parts = vec![format!("type={ext_type}"), format!("key={ext_key}")];
3430                if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3431                    attr_parts.push(format!("layout={layout}"));
3432                }
3433                if let Some(params) = attrs.get("parameters") {
3434                    if let Ok(json_str) = serde_json::to_string(params) {
3435                        attr_parts.push(format!("params='{json_str}'"));
3436                    }
3437                }
3438                maybe_push_local_id(attrs, &mut attr_parts, opts);
3439                output.push_str(&format!("::extension{{{}}}\n", attr_parts.join(" ")));
3440            }
3441        }
3442        "panel" => {
3443            let panel_type = node
3444                .attrs
3445                .as_ref()
3446                .and_then(|a| a.get("panelType"))
3447                .and_then(serde_json::Value::as_str)
3448                .unwrap_or("info");
3449            let mut attr_parts = vec![format!("type={panel_type}")];
3450            if let Some(ref attrs) = node.attrs {
3451                if let Some(icon) = attrs.get("panelIcon").and_then(serde_json::Value::as_str) {
3452                    attr_parts.push(format!("icon=\"{icon}\""));
3453                }
3454                if let Some(color) = attrs.get("panelColor").and_then(serde_json::Value::as_str) {
3455                    attr_parts.push(format!("color=\"{color}\""));
3456                }
3457            }
3458            output.push_str(&format!(":::panel{{{}}}\n", attr_parts.join(" ")));
3459            if let Some(ref content) = node.content {
3460                render_block_children(content, output, opts);
3461            }
3462            output.push_str(":::\n");
3463        }
3464        "expand" | "nestedExpand" => {
3465            let directive_name = if node.node_type == "nestedExpand" {
3466                "nested-expand"
3467            } else {
3468                "expand"
3469            };
3470            let mut attr_parts = Vec::new();
3471            if let Some(t) = node
3472                .attrs
3473                .as_ref()
3474                .and_then(|a| a.get("title"))
3475                .and_then(serde_json::Value::as_str)
3476            {
3477                attr_parts.push(format!("title=\"{t}\""));
3478            }
3479            // Check top-level localId first, then fall back to attrs.localId
3480            if let Some(ref lid) = node.local_id {
3481                if !opts.strip_local_ids && lid != "00000000-0000-0000-0000-000000000000" {
3482                    attr_parts.push(format!("localId={lid}"));
3483                }
3484            } else if let Some(ref attrs) = node.attrs {
3485                maybe_push_local_id(attrs, &mut attr_parts, opts);
3486            }
3487            // Emit top-level parameters as params='...'
3488            if let Some(ref params) = node.parameters {
3489                if let Ok(json_str) = serde_json::to_string(params) {
3490                    attr_parts.push(format!("params='{json_str}'"));
3491                }
3492            }
3493            if attr_parts.is_empty() {
3494                output.push_str(&format!(":::{directive_name}\n"));
3495            } else {
3496                output.push_str(&format!(
3497                    ":::{directive_name}{{{}}}\n",
3498                    attr_parts.join(" ")
3499                ));
3500            }
3501            if let Some(ref content) = node.content {
3502                render_block_children(content, output, opts);
3503            }
3504            output.push_str(":::\n");
3505        }
3506        "layoutSection" => {
3507            output.push_str("::::layout\n");
3508            if let Some(ref content) = node.content {
3509                for child in content {
3510                    if child.node_type == "layoutColumn" {
3511                        let width_str = child
3512                            .attrs
3513                            .as_ref()
3514                            .and_then(|a| a.get("width"))
3515                            .and_then(fmt_numeric_attr)
3516                            .unwrap_or_else(|| "50".to_string());
3517                        let mut parts = vec![format!("width={width_str}")];
3518                        if let Some(ref attrs) = child.attrs {
3519                            maybe_push_local_id(attrs, &mut parts, opts);
3520                        }
3521                        output.push_str(&format!(":::column{{{}}}\n", parts.join(" ")));
3522                        if let Some(ref col_content) = child.content {
3523                            render_block_children(col_content, output, opts);
3524                        }
3525                        output.push_str(":::\n");
3526                    }
3527                }
3528            }
3529            output.push_str("::::\n");
3530        }
3531        "decisionList" => {
3532            output.push_str(":::decisions\n");
3533            if let Some(ref content) = node.content {
3534                for item in content {
3535                    output.push_str("- <> ");
3536                    render_list_item_content(item, output, opts);
3537                }
3538            }
3539            output.push_str(":::\n");
3540        }
3541        "bodiedExtension" => {
3542            if let Some(ref attrs) = node.attrs {
3543                let ext_type = attrs
3544                    .get("extensionType")
3545                    .and_then(serde_json::Value::as_str)
3546                    .unwrap_or("");
3547                let ext_key = attrs
3548                    .get("extensionKey")
3549                    .and_then(serde_json::Value::as_str)
3550                    .unwrap_or("");
3551                let mut attr_parts = vec![format!("type={ext_type}"), format!("key={ext_key}")];
3552                if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3553                    attr_parts.push(format!("layout={layout}"));
3554                }
3555                if let Some(params) = attrs.get("parameters") {
3556                    if let Ok(json_str) = serde_json::to_string(params) {
3557                        attr_parts.push(format!("params='{json_str}'"));
3558                    }
3559                }
3560                maybe_push_local_id(attrs, &mut attr_parts, opts);
3561                output.push_str(&format!(":::extension{{{}}}\n", attr_parts.join(" ")));
3562                if let Some(ref content) = node.content {
3563                    render_block_children(content, output, opts);
3564                }
3565                output.push_str(":::\n");
3566            }
3567        }
3568        _ => {
3569            // Preserve unsupported nodes as JSON in adf-unsupported code blocks
3570            if let Ok(json) = serde_json::to_string_pretty(node) {
3571                output.push_str("```adf-unsupported\n");
3572                output.push_str(&json);
3573                output.push_str("\n```\n");
3574            }
3575        }
3576    }
3577
3578    // Emit block-level attribute marks (align, indent, breakout) and localId
3579    let mut parts = Vec::new();
3580    if let Some(ref marks) = node.marks {
3581        for mark in marks {
3582            match mark.mark_type.as_str() {
3583                "alignment" => {
3584                    if let Some(align) = mark
3585                        .attrs
3586                        .as_ref()
3587                        .and_then(|a| a.get("align"))
3588                        .and_then(serde_json::Value::as_str)
3589                    {
3590                        parts.push(format!("align={align}"));
3591                    }
3592                }
3593                "indentation" => {
3594                    if let Some(level) = mark
3595                        .attrs
3596                        .as_ref()
3597                        .and_then(|a| a.get("level"))
3598                        .and_then(serde_json::Value::as_u64)
3599                    {
3600                        parts.push(format!("indent={level}"));
3601                    }
3602                }
3603                "breakout" => {
3604                    if let Some(mode) = mark
3605                        .attrs
3606                        .as_ref()
3607                        .and_then(|a| a.get("mode"))
3608                        .and_then(serde_json::Value::as_str)
3609                    {
3610                        parts.push(format!("breakout={mode}"));
3611                    }
3612                    if let Some(width) = mark
3613                        .attrs
3614                        .as_ref()
3615                        .and_then(|a| a.get("width"))
3616                        .and_then(serde_json::Value::as_u64)
3617                    {
3618                        parts.push(format!("breakoutWidth={width}"));
3619                    }
3620                }
3621                _ => {}
3622            }
3623        }
3624    }
3625    // Skip localId for node types that already include it in their directive attrs.
3626    // For paragraphs, localId is included in the ::paragraph directive when the
3627    // paragraph uses directive form (empty or whitespace-only content).
3628    let para_used_directive = node.node_type == "paragraph" && {
3629        let is_empty = node.content.as_ref().map_or(true, Vec::is_empty);
3630        if is_empty {
3631            true
3632        } else {
3633            let mut buf = String::new();
3634            render_inline_content(node, &mut buf, opts);
3635            buf.trim().is_empty() && !buf.is_empty()
3636        }
3637    };
3638    if !matches!(node.node_type.as_str(), "expand" | "nestedExpand") && !para_used_directive {
3639        if let Some(ref attrs) = node.attrs {
3640            maybe_push_local_id(attrs, &mut parts, opts);
3641        }
3642    }
3643    // orderedList with explicit `attrs.order=1` needs a trailing `{order=1}`
3644    // signal so the round-trip can distinguish explicit default from omitted
3645    // attrs (issue #547). Values other than 1 are already encoded by the
3646    // list marker, so no signal is needed.
3647    if node.node_type == "orderedList" {
3648        if let Some(ref attrs) = node.attrs {
3649            if attrs.get("order").and_then(serde_json::Value::as_u64) == Some(1) {
3650                parts.push("order=1".to_string());
3651            }
3652        }
3653    }
3654    if !parts.is_empty() {
3655        output.push_str(&format!("{{{}}}\n", parts.join(" ")));
3656    }
3657}
3658
3659/// Renders the content of a list item (unwraps the paragraph layer).
3660/// Nested block children (e.g. sub-lists) are indented with two spaces.
3661///
3662/// Some ADF producers (e.g. Confluence) emit `taskItem` content without a
3663/// paragraph wrapper — the inline nodes sit directly inside the item.  We
3664/// detect this by checking whether the first child is an inline node type
3665/// and, if so, render *all* leading inline children on the first line.
3666fn render_list_item_content(item: &AdfNode, output: &mut String, opts: &RenderOptions) {
3667    let Some(ref content) = item.content else {
3668        // Still emit localId and newline for items with no content (e.g. empty taskItem).
3669        let bare = AdfNode::text("");
3670        emit_list_item_local_ids(item, &bare, output, opts);
3671        output.push('\n');
3672        return;
3673    };
3674    if content.is_empty() {
3675        let bare = AdfNode::text("");
3676        emit_list_item_local_ids(item, &bare, output, opts);
3677        output.push('\n');
3678        return;
3679    }
3680    let first = &content[0];
3681    let rest_start;
3682    if first.node_type == "paragraph" {
3683        let mut buf = String::new();
3684        render_inline_content(first, &mut buf, opts);
3685        // A trailing hardBreak produces a trailing `\\\n` in the buffer.
3686        // Strip the final newline so it doesn't create a blank line after
3687        // the list item marker, which would split the list on re-parse
3688        // (issue #472).  The `\` is kept so round-trip preserves the
3689        // hardBreak, and `output.push('\n')` below supplies the terminator.
3690        let buf = buf.trim_end_matches('\n');
3691        // Indent continuation lines produced by hardBreaks so they stay
3692        // within the list item when re-parsed (issue #402).
3693        let mut is_first_line = true;
3694        for line in buf.split('\n') {
3695            if is_first_line {
3696                output.push_str(line);
3697                is_first_line = false;
3698            } else {
3699                output.push('\n');
3700                if !line.is_empty() {
3701                    output.push_str("  ");
3702                }
3703                output.push_str(line);
3704            }
3705        }
3706        // Emit paragraph + listItem localIds as trailing inline attrs on the first line
3707        emit_list_item_local_ids(item, first, output, opts);
3708        output.push('\n');
3709        rest_start = 1;
3710    } else if is_inline_node_type(&first.node_type) {
3711        // Inline nodes without a paragraph wrapper — render them directly.
3712        rest_start = content
3713            .iter()
3714            .position(|c| !is_inline_node_type(&c.node_type))
3715            .unwrap_or(content.len());
3716        let mut buf = String::new();
3717        for child in &content[..rest_start] {
3718            render_inline_node(child, &mut buf, opts);
3719        }
3720        // Indent continuation lines produced by hardBreaks so they stay
3721        // within the list item when re-parsed (issue #521).
3722        let buf = buf.trim_end_matches('\n');
3723        let mut is_first_line = true;
3724        for line in buf.split('\n') {
3725            if is_first_line {
3726                output.push_str(line);
3727                is_first_line = false;
3728            } else {
3729                output.push('\n');
3730                if !line.is_empty() {
3731                    output.push_str("  ");
3732                }
3733                output.push_str(line);
3734            }
3735        }
3736        // No paragraph wrapper — pass a bare node so paraLocalId is omitted.
3737        let bare = AdfNode::text("");
3738        emit_list_item_local_ids(item, &bare, output, opts);
3739        output.push('\n');
3740        // Any remaining children are block nodes — fall through to the
3741        // indented-block loop below.
3742    } else if first.node_type == "taskItem" {
3743        // Malformed ADF: taskItem.content contains nested taskItem nodes
3744        // directly (seen in some Confluence pages).  Render them as an
3745        // indented nested task list to preserve the data without
3746        // corrupting the surrounding structure (issue #489).
3747        let bare = AdfNode::text("");
3748        emit_list_item_local_ids(item, &bare, output, opts);
3749        output.push('\n');
3750        for child in content {
3751            if child.node_type == "taskItem" {
3752                let state = child
3753                    .attrs
3754                    .as_ref()
3755                    .and_then(|a| a.get("state"))
3756                    .and_then(serde_json::Value::as_str)
3757                    .unwrap_or("TODO");
3758                let marker = if state == "DONE" { "- [x] " } else { "- [ ] " };
3759                output.push_str("  ");
3760                output.push_str(marker);
3761                render_list_item_content(child, output, opts);
3762            } else {
3763                let mut nested = String::new();
3764                render_block_node(child, &mut nested, opts);
3765                for line in nested.lines() {
3766                    output.push_str("  ");
3767                    output.push_str(line);
3768                    output.push('\n');
3769                }
3770            }
3771        }
3772        return;
3773    } else {
3774        // Block-level first child (e.g. codeBlock, mediaSingle).
3775        // Render to a buffer so we can:
3776        //  1. Append listItem localId attrs to the first line (issue #525)
3777        //  2. Indent continuation lines so multi-line blocks stay inside
3778        //     the list item (issue #511)
3779        let mut buf = String::new();
3780        render_block_node(first, &mut buf, opts);
3781        let bare = AdfNode::text("");
3782        let mut is_first = true;
3783        for line in buf.lines() {
3784            if is_first {
3785                output.push_str(line);
3786                emit_list_item_local_ids(item, &bare, output, opts);
3787                output.push('\n');
3788                is_first = false;
3789            } else {
3790                output.push_str("  ");
3791                output.push_str(line);
3792                output.push('\n');
3793            }
3794        }
3795        rest_start = 1;
3796    }
3797    let rest = &content[rest_start..];
3798    for (i, child) in rest.iter().enumerate() {
3799        // Separate consecutive paragraph siblings with a blank indented
3800        // line so they re-parse as distinct paragraphs rather than being
3801        // merged into one (issue #522).
3802        if child.node_type == "paragraph" {
3803            let prev_is_para = if i == 0 {
3804                // First rest child — check whether the first-line node
3805                // (rendered above) was a paragraph.
3806                first.node_type == "paragraph"
3807            } else {
3808                rest[i - 1].node_type == "paragraph"
3809            };
3810            if prev_is_para {
3811                output.push_str("  \n");
3812            }
3813        }
3814        let mut nested = String::new();
3815        render_block_node(child, &mut nested, opts);
3816        for line in nested.lines() {
3817            output.push_str("  ");
3818            output.push_str(line);
3819            output.push('\n');
3820        }
3821    }
3822}
3823
3824/// Returns `true` if the given ADF node type is an inline node.
3825fn is_inline_node_type(node_type: &str) -> bool {
3826    matches!(
3827        node_type,
3828        "text"
3829            | "hardBreak"
3830            | "inlineCard"
3831            | "emoji"
3832            | "mention"
3833            | "status"
3834            | "date"
3835            | "placeholder"
3836            | "mediaInline"
3837    )
3838}
3839
3840/// Emits trailing `{localId=... paraLocalId=...}` on a list item line
3841/// for both the listItem and its first (unwrapped) paragraph.
3842fn emit_list_item_local_ids(
3843    item: &AdfNode,
3844    paragraph: &AdfNode,
3845    output: &mut String,
3846    opts: &RenderOptions,
3847) {
3848    if opts.strip_local_ids {
3849        return;
3850    }
3851    let mut parts = Vec::new();
3852    if let Some(ref attrs) = item.attrs {
3853        maybe_push_local_id(attrs, &mut parts, opts);
3854    }
3855    if paragraph.node_type == "paragraph" {
3856        let has_real_id = paragraph
3857            .attrs
3858            .as_ref()
3859            .and_then(|a| a.get("localId"))
3860            .and_then(serde_json::Value::as_str)
3861            .filter(|id| !id.is_empty() && *id != "00000000-0000-0000-0000-000000000000");
3862        if let Some(local_id) = has_real_id {
3863            parts.push(format!("paraLocalId={local_id}"));
3864        } else if item.node_type == "taskItem" {
3865            // taskItem content may or may not have a paragraph wrapper;
3866            // emit a sentinel so the round-trip can distinguish the two
3867            // forms and restore the wrapper (issue #478).
3868            parts.push("paraLocalId=_".to_string());
3869        }
3870    }
3871    if !parts.is_empty() {
3872        output.push_str(&format!(" {{{}}}", parts.join(" ")));
3873    }
3874}
3875
3876/// Renders a table node, choosing between pipe table and directive table form.
3877fn render_table(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
3878    let Some(ref rows) = node.content else {
3879        return;
3880    };
3881
3882    if table_qualifies_for_pipe_syntax(rows) {
3883        render_pipe_table(node, rows, output, opts);
3884    } else {
3885        render_directive_table(node, rows, output, opts);
3886    }
3887}
3888
3889/// Checks whether all cells qualify for GFM pipe table syntax:
3890/// - Every cell has exactly one paragraph child with only inline nodes
3891/// - All `tableHeader` nodes appear exclusively in the first row
3892/// - The first row must contain at least one `tableHeader` (pipe tables
3893///   always treat the first row as headers, so `tableCell`-only first rows
3894///   must use directive form to preserve the cell type)
3895fn table_qualifies_for_pipe_syntax(rows: &[AdfNode]) -> bool {
3896    // Tables with caption nodes must use directive form
3897    if rows.iter().any(|n| n.node_type == "caption") {
3898        return false;
3899    }
3900    let mut first_row_has_header = false;
3901    for (row_idx, row) in rows.iter().enumerate() {
3902        let Some(ref cells) = row.content else {
3903            continue;
3904        };
3905        for cell in cells {
3906            // Header cells outside first row → must use directive form
3907            if row_idx > 0 && cell.node_type == "tableHeader" {
3908                return false;
3909            }
3910            if row_idx == 0 && cell.node_type == "tableHeader" {
3911                first_row_has_header = true;
3912            }
3913            // Check cell has exactly one paragraph with only inline content
3914            let Some(ref content) = cell.content else {
3915                continue;
3916            };
3917            if content.len() != 1 || content[0].node_type != "paragraph" {
3918                return false;
3919            }
3920            // hardBreak inside a cell produces a newline that breaks pipe
3921            // table syntax — fall back to directive form
3922            if cell_contains_hard_break(&content[0]) {
3923                return false;
3924            }
3925            // Cell-level marks (e.g., border) cannot be represented in pipe
3926            // form — fall back to directive form
3927            if cell.marks.as_ref().is_some_and(|m| !m.is_empty()) {
3928                return false;
3929            }
3930            // Paragraph-level localId would be lost in pipe form (the paragraph
3931            // is unwrapped into the cell text) — fall back to directive form
3932            if content[0]
3933                .attrs
3934                .as_ref()
3935                .and_then(|a| a.get("localId"))
3936                .is_some()
3937            {
3938                return false;
3939            }
3940        }
3941    }
3942    // First row must have at least one tableHeader for pipe syntax;
3943    // otherwise the round-trip would convert tableCell → tableHeader.
3944    first_row_has_header
3945}
3946
3947/// Returns true if a paragraph node contains any `hardBreak` inline nodes.
3948fn cell_contains_hard_break(paragraph: &AdfNode) -> bool {
3949    paragraph
3950        .content
3951        .as_ref()
3952        .is_some_and(|nodes| nodes.iter().any(|n| n.node_type == "hardBreak"))
3953}
3954
3955/// Renders a table as GFM pipe syntax.
3956fn render_pipe_table(node: &AdfNode, rows: &[AdfNode], output: &mut String, opts: &RenderOptions) {
3957    for (row_idx, row) in rows.iter().enumerate() {
3958        let Some(ref cells) = row.content else {
3959            continue;
3960        };
3961
3962        output.push('|');
3963        for cell in cells {
3964            output.push(' ');
3965            let mut cell_buf = String::new();
3966            render_cell_attrs_prefix(cell, &mut cell_buf);
3967            render_inline_content_from_first_paragraph(cell, &mut cell_buf, opts);
3968            output.push_str(&escape_pipes_in_cell(&cell_buf));
3969            output.push_str(" |");
3970        }
3971        output.push('\n');
3972
3973        // Add separator after header row
3974        if row_idx == 0 {
3975            output.push('|');
3976            for cell in cells {
3977                let align = get_cell_paragraph_alignment(cell);
3978                match align {
3979                    Some("center") => output.push_str(" :---: |"),
3980                    Some("end") => output.push_str(" ---: |"),
3981                    _ => output.push_str(" --- |"),
3982                }
3983            }
3984            output.push('\n');
3985        }
3986    }
3987
3988    // Emit table-level attrs if present
3989    render_table_level_attrs(node, output, opts);
3990}
3991
3992/// Renders a table as `::::table` directive syntax (block-content cells).
3993fn render_directive_table(
3994    node: &AdfNode,
3995    rows: &[AdfNode],
3996    output: &mut String,
3997    opts: &RenderOptions,
3998) {
3999    // Opening fence with attrs
4000    let mut attr_parts = Vec::new();
4001    if let Some(ref attrs) = node.attrs {
4002        if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
4003            attr_parts.push(format!("layout={layout}"));
4004        }
4005        if let Some(numbered) = attrs
4006            .get("isNumberColumnEnabled")
4007            .and_then(serde_json::Value::as_bool)
4008        {
4009            if numbered {
4010                attr_parts.push("numbered".to_string());
4011            } else {
4012                attr_parts.push("numbered=false".to_string());
4013            }
4014        }
4015        if let Some(tw) = attrs.get("width").and_then(serde_json::Value::as_f64) {
4016            let tw_str = if tw.fract() == 0.0 {
4017                (tw as u64).to_string()
4018            } else {
4019                tw.to_string()
4020            };
4021            attr_parts.push(format!("width={tw_str}"));
4022        }
4023        maybe_push_local_id(attrs, &mut attr_parts, opts);
4024    }
4025    if attr_parts.is_empty() {
4026        output.push_str("::::table\n");
4027    } else {
4028        output.push_str(&format!("::::table{{{}}}\n", attr_parts.join(" ")));
4029    }
4030
4031    for row in rows {
4032        if row.node_type == "caption" {
4033            let mut cap_parts = Vec::new();
4034            if let Some(ref attrs) = row.attrs {
4035                maybe_push_local_id(attrs, &mut cap_parts, opts);
4036            }
4037            if cap_parts.is_empty() {
4038                output.push_str(":::caption\n");
4039            } else {
4040                output.push_str(&format!(":::caption{{{}}}\n", cap_parts.join(" ")));
4041            }
4042            if let Some(ref content) = row.content {
4043                for child in content {
4044                    render_inline_node(child, output, opts);
4045                }
4046                output.push('\n');
4047            }
4048            output.push_str(":::\n");
4049            continue;
4050        }
4051        let Some(ref cells) = row.content else {
4052            continue;
4053        };
4054        // Emit :::tr with optional localId
4055        let mut tr_attrs = Vec::new();
4056        if let Some(ref attrs) = row.attrs {
4057            maybe_push_local_id(attrs, &mut tr_attrs, opts);
4058        }
4059        if tr_attrs.is_empty() {
4060            output.push_str(":::tr\n");
4061        } else {
4062            output.push_str(&format!(":::tr{{{}}}\n", tr_attrs.join(" ")));
4063        }
4064        for cell in cells {
4065            let directive_name = if cell.node_type == "tableHeader" {
4066                "th"
4067            } else {
4068                "td"
4069            };
4070            let mut cell_attr_str = build_cell_attrs_string(cell);
4071            // Append localId to cell attrs if present
4072            if let Some(ref attrs) = cell.attrs {
4073                let mut lid_parts = Vec::new();
4074                maybe_push_local_id(attrs, &mut lid_parts, opts);
4075                if !lid_parts.is_empty() {
4076                    if !cell_attr_str.is_empty() {
4077                        cell_attr_str.push(' ');
4078                    }
4079                    cell_attr_str.push_str(&lid_parts.join(" "));
4080                }
4081            }
4082            // Append border mark attrs if present
4083            if let Some(ref marks) = cell.marks {
4084                for mark in marks {
4085                    if mark.mark_type == "border" {
4086                        if let Some(ref attrs) = mark.attrs {
4087                            if let Some(color) =
4088                                attrs.get("color").and_then(serde_json::Value::as_str)
4089                            {
4090                                if !cell_attr_str.is_empty() {
4091                                    cell_attr_str.push(' ');
4092                                }
4093                                cell_attr_str.push_str(&format!("border-color={color}"));
4094                            }
4095                            if let Some(size) =
4096                                attrs.get("size").and_then(serde_json::Value::as_u64)
4097                            {
4098                                if !cell_attr_str.is_empty() {
4099                                    cell_attr_str.push(' ');
4100                                }
4101                                cell_attr_str.push_str(&format!("border-size={size}"));
4102                            }
4103                        }
4104                    }
4105                }
4106            }
4107            let has_marks = cell.marks.as_ref().is_some_and(|m| !m.is_empty());
4108            if cell_attr_str.is_empty() && cell.attrs.is_none() && !has_marks {
4109                output.push_str(&format!(":::{directive_name}\n"));
4110            } else {
4111                output.push_str(&format!(":::{directive_name}{{{cell_attr_str}}}\n"));
4112            }
4113            if let Some(ref content) = cell.content {
4114                render_block_children(content, output, opts);
4115            }
4116            output.push_str(":::\n");
4117        }
4118        output.push_str(":::\n");
4119    }
4120
4121    output.push_str("::::\n");
4122}
4123
4124/// Returns `true` when an attribute value must be quoted to survive round-trip
4125/// through the `{key=value}` attribute parser (which stops unquoted values at
4126/// whitespace or `}`).
4127fn needs_attr_quoting(value: &str) -> bool {
4128    value.contains(|c: char| c.is_whitespace() || c == '}' || c == '(' || c == ')' || c == ',')
4129}
4130
4131/// Builds a JFM attribute string from ADF cell attributes.
4132fn build_cell_attrs_string(cell: &AdfNode) -> String {
4133    let Some(ref attrs) = cell.attrs else {
4134        return String::new();
4135    };
4136    let mut parts = Vec::new();
4137    if let Some(colspan) = attrs.get("colspan").and_then(serde_json::Value::as_u64) {
4138        parts.push(format!("colspan={colspan}"));
4139    }
4140    if let Some(rowspan) = attrs.get("rowspan").and_then(serde_json::Value::as_u64) {
4141        parts.push(format!("rowspan={rowspan}"));
4142    }
4143    if let Some(bg) = attrs.get("background").and_then(serde_json::Value::as_str) {
4144        if needs_attr_quoting(bg) {
4145            let escaped = bg.replace('\\', "\\\\").replace('"', "\\\"");
4146            parts.push(format!("bg=\"{escaped}\""));
4147        } else {
4148            parts.push(format!("bg={bg}"));
4149        }
4150    }
4151    if let Some(colwidth) = attrs.get("colwidth").and_then(serde_json::Value::as_array) {
4152        let widths: Vec<String> = colwidth
4153            .iter()
4154            .filter_map(|v| {
4155                // Preserve the original number type: integers stay as integers,
4156                // floats stay as floats (e.g. Confluence's 254.0 representation).
4157                if let Some(n) = v.as_u64() {
4158                    Some(n.to_string())
4159                } else if let Some(n) = v.as_f64() {
4160                    if n.fract() == 0.0 {
4161                        format!("{n:.1}")
4162                    } else {
4163                        n.to_string()
4164                    }
4165                    .into()
4166                } else {
4167                    None
4168                }
4169            })
4170            .collect();
4171        if !widths.is_empty() {
4172            parts.push(format!("colwidth={}", widths.join(",")));
4173        }
4174    }
4175    parts.join(" ")
4176}
4177
4178/// Renders `{attrs}` prefix for a pipe table cell (background, colspan, etc.).
4179fn render_cell_attrs_prefix(cell: &AdfNode, output: &mut String) {
4180    let Some(ref _attrs) = cell.attrs else {
4181        return;
4182    };
4183    let attr_str = build_cell_attrs_string(cell);
4184    if attr_str.is_empty() {
4185        output.push_str("{} ");
4186    } else {
4187        output.push_str(&format!("{{{attr_str}}} "));
4188    }
4189}
4190
4191/// Gets the alignment mark value from the paragraph inside a table cell.
4192fn get_cell_paragraph_alignment(cell: &AdfNode) -> Option<&str> {
4193    let content = cell.content.as_ref()?;
4194    let para = content.first()?;
4195    let marks = para.marks.as_ref()?;
4196    marks.iter().find_map(|m| {
4197        if m.mark_type == "alignment" {
4198            m.attrs
4199                .as_ref()
4200                .and_then(|a| a.get("align"))
4201                .and_then(serde_json::Value::as_str)
4202        } else {
4203            None
4204        }
4205    })
4206}
4207
4208/// Emits table-level attributes if present.
4209fn render_table_level_attrs(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4210    if let Some(ref attrs) = node.attrs {
4211        let mut parts = Vec::new();
4212        if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
4213            parts.push(format!("layout={layout}"));
4214        }
4215        if let Some(numbered) = attrs
4216            .get("isNumberColumnEnabled")
4217            .and_then(serde_json::Value::as_bool)
4218        {
4219            if numbered {
4220                parts.push("numbered".to_string());
4221            } else {
4222                parts.push("numbered=false".to_string());
4223            }
4224        }
4225        if let Some(tw_str) = attrs.get("width").and_then(fmt_numeric_attr) {
4226            parts.push(format!("width={tw_str}"));
4227        }
4228        maybe_push_local_id(attrs, &mut parts, opts);
4229        if !parts.is_empty() {
4230            output.push_str(&format!("{{{}}}\n", parts.join(" ")));
4231        }
4232    }
4233}
4234
4235/// Renders inline content from the first paragraph child of a table cell.
4236fn render_inline_content_from_first_paragraph(
4237    cell: &AdfNode,
4238    output: &mut String,
4239    opts: &RenderOptions,
4240) {
4241    if let Some(ref content) = cell.content {
4242        if let Some(first) = content.first() {
4243            if first.node_type == "paragraph" {
4244                render_inline_content(first, output, opts);
4245            }
4246        }
4247    }
4248}
4249
4250/// Appends border mark attributes (border-color, border-size) to a parts vec.
4251fn push_border_mark_attrs(marks: &Option<Vec<AdfMark>>, parts: &mut Vec<String>) {
4252    if let Some(ref marks) = marks {
4253        for mark in marks {
4254            if mark.mark_type == "border" {
4255                if let Some(ref attrs) = mark.attrs {
4256                    if let Some(color) = attrs.get("color").and_then(serde_json::Value::as_str) {
4257                        parts.push(format!("border-color={color}"));
4258                    }
4259                    if let Some(size) = attrs.get("size").and_then(serde_json::Value::as_u64) {
4260                        parts.push(format!("border-size={size}"));
4261                    }
4262                }
4263            }
4264        }
4265    }
4266}
4267
4268/// Renders a media node as a markdown image, with optional parent (mediaSingle) attrs.
4269fn render_media(
4270    node: &AdfNode,
4271    parent_attrs: Option<&serde_json::Value>,
4272    output: &mut String,
4273    opts: &RenderOptions,
4274) {
4275    if let Some(ref attrs) = node.attrs {
4276        let media_type = attrs
4277            .get("type")
4278            .and_then(serde_json::Value::as_str)
4279            .unwrap_or("external");
4280        let alt = attrs
4281            .get("alt")
4282            .and_then(serde_json::Value::as_str)
4283            .unwrap_or("");
4284
4285        if media_type == "file" {
4286            // Confluence file attachment — encode metadata in {attrs} block so it survives round-trip
4287            output.push_str(&format!("![{alt}]()"));
4288            let mut parts = vec!["type=file".to_string()];
4289            if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4290                parts.push(format_kv("id", id));
4291            }
4292            if let Some(collection) = attrs.get("collection").and_then(serde_json::Value::as_str) {
4293                parts.push(format_kv("collection", collection));
4294            }
4295            if let Some(occurrence_key) = attrs
4296                .get("occurrenceKey")
4297                .and_then(serde_json::Value::as_str)
4298            {
4299                parts.push(format_kv("occurrenceKey", occurrence_key));
4300            }
4301            if let Some(height_str) = attrs.get("height").and_then(fmt_numeric_attr) {
4302                parts.push(format!("height={height_str}"));
4303            }
4304            if let Some(width_str) = attrs.get("width").and_then(fmt_numeric_attr) {
4305                parts.push(format!("width={width_str}"));
4306            }
4307            maybe_push_local_id(attrs, &mut parts, opts);
4308            // Encode mediaSingle layout/width/widthType if non-default
4309            if let Some(p_attrs) = parent_attrs {
4310                if let Some(layout) = p_attrs.get("layout").and_then(serde_json::Value::as_str) {
4311                    if layout != "center" {
4312                        parts.push(format!("layout={layout}"));
4313                    }
4314                }
4315                if let Some(ms_width_str) = p_attrs.get("width").and_then(fmt_numeric_attr) {
4316                    parts.push(format!("mediaWidth={ms_width_str}"));
4317                }
4318                if let Some(wt) = p_attrs.get("widthType").and_then(serde_json::Value::as_str) {
4319                    parts.push(format!("widthType={wt}"));
4320                }
4321                if let Some(mode) = p_attrs.get("mode").and_then(serde_json::Value::as_str) {
4322                    parts.push(format!("mode={mode}"));
4323                }
4324            }
4325            push_border_mark_attrs(&node.marks, &mut parts);
4326            output.push_str(&format!("{{{}}}", parts.join(" ")));
4327        } else {
4328            // External image
4329            let url = attrs
4330                .get("url")
4331                .and_then(serde_json::Value::as_str)
4332                .unwrap_or("");
4333            output.push_str(&format!("![{alt}]({url})"));
4334
4335            // Emit {layout=... width=... widthType=... mode=... localId=...} if non-default attrs present
4336            {
4337                let mut parts = Vec::new();
4338                if let Some(p_attrs) = parent_attrs {
4339                    let layout = p_attrs.get("layout").and_then(serde_json::Value::as_str);
4340                    let width_str = p_attrs.get("width").and_then(fmt_numeric_attr);
4341                    let width_type = p_attrs.get("widthType").and_then(serde_json::Value::as_str);
4342                    if let Some(l) = layout {
4343                        if l != "center" {
4344                            parts.push(format!("layout={l}"));
4345                        }
4346                    }
4347                    if let Some(w) = width_str {
4348                        parts.push(format!("width={w}"));
4349                    }
4350                    if let Some(wt) = width_type {
4351                        parts.push(format!("widthType={wt}"));
4352                    }
4353                    if let Some(mode) = p_attrs.get("mode").and_then(serde_json::Value::as_str) {
4354                        parts.push(format!("mode={mode}"));
4355                    }
4356                }
4357                maybe_push_local_id(attrs, &mut parts, opts);
4358                push_border_mark_attrs(&node.marks, &mut parts);
4359                if !parts.is_empty() {
4360                    output.push_str(&format!("{{{}}}", parts.join(" ")));
4361                }
4362            }
4363        }
4364
4365        output.push('\n');
4366    }
4367}
4368
4369/// Renders inline content (text nodes with marks) from a block node's children.
4370fn render_inline_content(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4371    if let Some(ref content) = node.content {
4372        for child in content {
4373            render_inline_node(child, output, opts);
4374        }
4375    }
4376}
4377
4378/// Renders a single inline ADF node to markdown.
4379fn render_inline_node(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4380    match node.node_type.as_str() {
4381        "text" => {
4382            let text = node.text.as_deref().unwrap_or("");
4383            let marks = node.marks.as_deref().unwrap_or(&[]);
4384            let has_code = marks.iter().any(|m| m.mark_type == "code");
4385            // Issue #477: Escape literal backslashes before the newline
4386            // encoding below so they are not consumed as JFM escape
4387            // sequences on round-trip.  Code marks emit content verbatim,
4388            // so backslash escaping is skipped for them.
4389            let owned;
4390            let text = if !has_code {
4391                owned = text.replace('\\', "\\\\");
4392                owned.as_str()
4393            } else {
4394                text
4395            };
4396            // Issue #454: A literal newline inside a text node is escaped
4397            // as the two-character sequence `\n` so it survives round-trip
4398            // as a single text node instead of splitting into paragraphs or
4399            // being converted to hardBreak nodes.
4400            let owned_nl;
4401            let text = if text.contains('\n') {
4402                owned_nl = text.replace('\n', "\\n");
4403                owned_nl.as_str()
4404            } else {
4405                text
4406            };
4407            // Issue #510: Two or more trailing spaces at the end of a text
4408            // node would be misinterpreted as a hardBreak marker on
4409            // round-trip (and collapse the following paragraph).  Escape the
4410            // last space with a backslash so the parser treats it as a
4411            // literal space instead of a line-break signal.
4412            let owned_ts;
4413            let text = if !has_code && text.ends_with("  ") {
4414                let mut s = text.to_string();
4415                // Insert backslash before the final space: "foo  " → "foo \ "
4416                s.insert(s.len() - 1, '\\');
4417                owned_ts = s;
4418                owned_ts.as_str()
4419            } else {
4420                text
4421            };
4422            render_marked_text(text, marks, output);
4423        }
4424        "hardBreak" => {
4425            output.push_str("\\\n");
4426        }
4427        other => {
4428            // Issue #471: Non-text inline nodes (emoji, status, date, mention, etc.)
4429            // may carry annotation marks. Render the node body first, then wrap it
4430            // in bracketed-span syntax if annotation marks are present.
4431            let mut body = String::new();
4432            render_non_text_inline_body(other, node, &mut body, opts);
4433
4434            let annotations: Vec<&AdfMark> = node
4435                .marks
4436                .as_deref()
4437                .unwrap_or(&[])
4438                .iter()
4439                .filter(|m| m.mark_type == "annotation")
4440                .collect();
4441
4442            if annotations.is_empty() {
4443                output.push_str(&body);
4444            } else {
4445                let mut attr_parts = Vec::new();
4446                for ann in &annotations {
4447                    if let Some(ref attrs) = ann.attrs {
4448                        if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4449                            let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4450                            attr_parts.push(format!("annotation-id=\"{escaped}\""));
4451                        }
4452                        if let Some(at) = attrs
4453                            .get("annotationType")
4454                            .and_then(serde_json::Value::as_str)
4455                        {
4456                            attr_parts.push(format!("annotation-type={at}"));
4457                        }
4458                    }
4459                }
4460                output.push('[');
4461                output.push_str(&body);
4462                output.push_str("]{");
4463                output.push_str(&attr_parts.join(" "));
4464                output.push('}');
4465            }
4466        }
4467    }
4468}
4469
4470/// Renders the body of a non-text inline node (without mark wrapping).
4471fn render_non_text_inline_body(
4472    node_type: &str,
4473    node: &AdfNode,
4474    output: &mut String,
4475    opts: &RenderOptions,
4476) {
4477    match node_type {
4478        "inlineCard" => {
4479            if let Some(ref attrs) = node.attrs {
4480                if let Some(url) = attrs.get("url").and_then(serde_json::Value::as_str) {
4481                    let mut attr_parts = Vec::new();
4482                    if url_safe_in_bracket_content(url) {
4483                        output.push_str(":card[");
4484                        output.push_str(url);
4485                        output.push(']');
4486                    } else {
4487                        // URL would break `:card[URL]` parsing (e.g. contains an
4488                        // unbalanced `]` or a newline).  Fall back to quoted
4489                        // attribute form so the URL round-trips losslessly.
4490                        output.push_str(":card[]");
4491                        let escaped = url.replace('\\', "\\\\").replace('"', "\\\"");
4492                        attr_parts.push(format!("url=\"{escaped}\""));
4493                    }
4494                    maybe_push_local_id(attrs, &mut attr_parts, opts);
4495                    if !attr_parts.is_empty() {
4496                        output.push('{');
4497                        output.push_str(&attr_parts.join(" "));
4498                        output.push('}');
4499                    }
4500                }
4501            }
4502        }
4503        "emoji" => {
4504            if let Some(ref attrs) = node.attrs {
4505                if let Some(short_name) = attrs.get("shortName").and_then(serde_json::Value::as_str)
4506                {
4507                    output.push(':');
4508                    let name = short_name.strip_prefix(':').unwrap_or(short_name);
4509                    let name = name.strip_suffix(':').unwrap_or(name);
4510                    output.push_str(name);
4511                    output.push(':');
4512
4513                    let mut parts = Vec::new();
4514                    let escaped_sn = short_name.replace('\\', "\\\\").replace('"', "\\\"");
4515                    parts.push(format!("shortName=\"{escaped_sn}\""));
4516                    if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4517                        let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4518                        parts.push(format!("id=\"{escaped}\""));
4519                    }
4520                    if let Some(text) = attrs.get("text").and_then(serde_json::Value::as_str) {
4521                        let escaped = text.replace('\\', "\\\\").replace('"', "\\\"");
4522                        parts.push(format!("text=\"{escaped}\""));
4523                    }
4524                    maybe_push_local_id(attrs, &mut parts, opts);
4525                    output.push('{');
4526                    output.push_str(&parts.join(" "));
4527                    output.push('}');
4528                }
4529            }
4530        }
4531        "status" => {
4532            if let Some(ref attrs) = node.attrs {
4533                let text = attrs
4534                    .get("text")
4535                    .and_then(serde_json::Value::as_str)
4536                    .unwrap_or("");
4537                let color = attrs
4538                    .get("color")
4539                    .and_then(serde_json::Value::as_str)
4540                    .unwrap_or("neutral");
4541                let mut attr_parts = vec![format!("color={color}")];
4542                if let Some(style) = attrs.get("style").and_then(serde_json::Value::as_str) {
4543                    attr_parts.push(format!("style={style}"));
4544                }
4545                maybe_push_local_id(attrs, &mut attr_parts, opts);
4546                output.push_str(&format!(":status[{text}]{{{}}}", attr_parts.join(" ")));
4547            }
4548        }
4549        "date" => {
4550            if let Some(ref attrs) = node.attrs {
4551                if let Some(timestamp) = attrs.get("timestamp").and_then(serde_json::Value::as_str)
4552                {
4553                    let display = epoch_ms_to_iso_date(timestamp);
4554                    let mut attr_parts = vec![format!("timestamp={timestamp}")];
4555                    maybe_push_local_id(attrs, &mut attr_parts, opts);
4556                    output.push_str(&format!(":date[{display}]{{{}}}", attr_parts.join(" ")));
4557                }
4558            }
4559        }
4560        "mention" => {
4561            if let Some(ref attrs) = node.attrs {
4562                let id = attrs
4563                    .get("id")
4564                    .and_then(serde_json::Value::as_str)
4565                    .unwrap_or("");
4566                let text = attrs
4567                    .get("text")
4568                    .and_then(serde_json::Value::as_str)
4569                    .unwrap_or("");
4570                let mut attr_parts = vec![format!("id={id}")];
4571                if let Some(ut) = attrs.get("userType").and_then(serde_json::Value::as_str) {
4572                    attr_parts.push(format!("userType={ut}"));
4573                }
4574                if let Some(al) = attrs.get("accessLevel").and_then(serde_json::Value::as_str) {
4575                    attr_parts.push(format!("accessLevel={al}"));
4576                }
4577                maybe_push_local_id(attrs, &mut attr_parts, opts);
4578                output.push_str(&format!(":mention[{text}]{{{}}}", attr_parts.join(" ")));
4579            }
4580        }
4581        "placeholder" => {
4582            if let Some(ref attrs) = node.attrs {
4583                let text = attrs
4584                    .get("text")
4585                    .and_then(serde_json::Value::as_str)
4586                    .unwrap_or("");
4587                output.push_str(&format!(":placeholder[{text}]"));
4588            }
4589        }
4590        "inlineExtension" => {
4591            if let Some(ref attrs) = node.attrs {
4592                let ext_type = attrs
4593                    .get("extensionType")
4594                    .and_then(serde_json::Value::as_str)
4595                    .unwrap_or("");
4596                let ext_key = attrs
4597                    .get("extensionKey")
4598                    .and_then(serde_json::Value::as_str)
4599                    .unwrap_or("");
4600                let fallback = node.text.as_deref().unwrap_or("");
4601                output.push_str(&format!(
4602                    ":extension[{fallback}]{{type={ext_type} key={ext_key}}}"
4603                ));
4604            }
4605        }
4606        "mediaInline" => {
4607            if let Some(ref attrs) = node.attrs {
4608                let mut attr_parts = Vec::new();
4609                if let Some(media_type) = attrs.get("type").and_then(serde_json::Value::as_str) {
4610                    attr_parts.push(format_kv("type", media_type));
4611                }
4612                if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4613                    attr_parts.push(format_kv("id", id));
4614                }
4615                if let Some(collection) =
4616                    attrs.get("collection").and_then(serde_json::Value::as_str)
4617                {
4618                    attr_parts.push(format_kv("collection", collection));
4619                }
4620                if let Some(url) = attrs.get("url").and_then(serde_json::Value::as_str) {
4621                    attr_parts.push(format_kv("url", url));
4622                }
4623                if let Some(alt) = attrs.get("alt").and_then(serde_json::Value::as_str) {
4624                    attr_parts.push(format_kv("alt", alt));
4625                }
4626                if let Some(width) = attrs.get("width").and_then(serde_json::Value::as_u64) {
4627                    attr_parts.push(format!("width={width}"));
4628                }
4629                if let Some(height) = attrs.get("height").and_then(serde_json::Value::as_u64) {
4630                    attr_parts.push(format!("height={height}"));
4631                }
4632                maybe_push_local_id(attrs, &mut attr_parts, opts);
4633                output.push_str(&format!(":media-inline[]{{{}}}", attr_parts.join(" ")));
4634            }
4635        }
4636        _ => {
4637            output.push_str(&format!("<!-- unsupported inline: {} -->", node.node_type));
4638        }
4639    }
4640}
4641
4642/// Renders text with ADF marks applied as markdown syntax.
4643///
4644/// Mark ordering is preserved by walking the marks array in order and emitting
4645/// one wrapper per mark (outermost first, innermost last).  The resulting
4646/// markdown round-trips back to the original mark sequence because the parser
4647/// reconstructs marks outside-in from the nested delimiter structure.
4648///
4649/// When both `strong` and `em` are present, em is rendered with `_` instead of
4650/// `*` to avoid ambiguity (e.g., `_**text**_` rather than `***text***`).  The
4651/// single exception is `[strong, em]` (exactly those two marks in that order),
4652/// which is rendered as `***text***` to preserve the familiar compact form;
4653/// the parser's triple-delimiter rule round-trips it back to `[strong, em]`.
4654fn render_marked_text(text: &str, marks: &[AdfMark], output: &mut String) {
4655    if marks.iter().any(|m| m.mark_type == "code") {
4656        render_code_marked_text(text, marks, output);
4657        return;
4658    }
4659
4660    let has_link = marks.iter().any(|m| m.mark_type == "link");
4661    let has_strong = marks.iter().any(|m| m.mark_type == "strong");
4662    let has_em = marks.iter().any(|m| m.mark_type == "em");
4663
4664    // Compact form for the common [strong, em] case: ***text***.  em is
4665    // rendered with `*` here (as part of the `***` triple delimiter), so
4666    // underscores in the content don't need escaping.
4667    if marks.len() == 2 && marks[0].mark_type == "strong" && marks[1].mark_type == "em" {
4668        let escaped = escape_emphasis_markers(text);
4669        let escaped = escape_emoji_shortcodes(&escaped);
4670        let escaped = escape_backticks(&escaped);
4671        let escaped = escape_bare_urls(&escaped);
4672        output.push_str("***");
4673        output.push_str(&escaped);
4674        output.push_str("***");
4675        return;
4676    }
4677
4678    // When both strong and em are present (in any order), em uses `_` instead
4679    // of `*` to avoid the `***` triple-delimiter ambiguity.  Otherwise em uses
4680    // `*`, which sidesteps intraword-underscore pitfalls for plain em text.
4681    let em_delim = if has_strong && has_em { "_" } else { "*" };
4682
4683    // Text must also escape `_` when em renders as `_..._` — otherwise any
4684    // underscore in the content would close the emphasis span early.
4685    let escaped = if em_delim == "_" {
4686        escape_emphasis_markers_with_underscore(text)
4687    } else {
4688        escape_emphasis_markers(text)
4689    };
4690    let escaped = escape_emoji_shortcodes(&escaped);
4691    let escaped = escape_backticks(&escaped);
4692    // Always escape bare URLs so they are not re-parsed as `inlineCard`
4693    // nodes on round-trip.  When the text carries a link mark, also escape
4694    // `[` and `]` so they do not terminate the enclosing `[…]` link syntax
4695    // (issue #493).  Escaping bare URLs inside link text additionally
4696    // prevents `\[`/`\]` escapes from leaking through the URL-as-link-text
4697    // fast path and from corrupting an auto-detected bare URL inside the
4698    // link display text (issue #551).
4699    let escaped = escape_bare_urls(&escaped);
4700    let escaped = if has_link {
4701        escape_link_brackets(&escaped)
4702    } else {
4703        escaped
4704    };
4705
4706    // Collect (open, close) wrappers in mark order, outermost first.  Consecutive
4707    // span-attr or bracketed-span marks that happen to be in the parser's
4708    // canonical order (so the merged wrapper parses back to the same mark
4709    // sequence) are merged into a single wrapper; otherwise each mark gets its
4710    // own nested wrapper so that the mark ordering survives the round-trip.
4711    let mut wrappers: Vec<(String, String)> = Vec::new();
4712    let mut i = 0;
4713    while i < marks.len() {
4714        match marks[i].mark_type.as_str() {
4715            "em" => {
4716                wrappers.push((em_delim.to_string(), em_delim.to_string()));
4717                i += 1;
4718            }
4719            "strong" => {
4720                wrappers.push(("**".to_string(), "**".to_string()));
4721                i += 1;
4722            }
4723            "strike" => {
4724                wrappers.push(("~~".to_string(), "~~".to_string()));
4725                i += 1;
4726            }
4727            "link" => {
4728                let href = link_href(&marks[i]);
4729                wrappers.push(("[".to_string(), format!("]({href})")));
4730                i += 1;
4731            }
4732            "textColor" | "backgroundColor" | "subsup" => {
4733                let start = i;
4734                while i < marks.len() && is_span_attr_mark(&marks[i].mark_type) {
4735                    i += 1;
4736                }
4737                emit_span_attr_wrappers(&marks[start..i], &mut wrappers);
4738            }
4739            "underline" | "annotation" => {
4740                let start = i;
4741                while i < marks.len() && is_bracketed_span_mark(&marks[i].mark_type) {
4742                    i += 1;
4743                }
4744                emit_bracketed_wrappers(&marks[start..i], &mut wrappers);
4745            }
4746            _ => {
4747                i += 1;
4748            }
4749        }
4750    }
4751
4752    // Apply wrappers from innermost (last) to outermost (first).
4753    let mut result = escaped;
4754    for (open, close) in wrappers.iter().rev() {
4755        result.insert_str(0, open);
4756        result.push_str(close);
4757    }
4758    output.push_str(&result);
4759}
4760
4761/// Renders a text node with a `code` mark.  Code content is emitted verbatim
4762/// inside backticks, optionally wrapped by a link and/or by `:span`/bracketed-
4763/// span carrying span-attr (`textColor`, `backgroundColor`, `subsup`) and
4764/// bracketed-span (`underline`, `annotation`) marks.  No `em`/`strong`/`strike`
4765/// formatting is applied because markdown code spans do not support nested
4766/// emphasis (issue #554: previously textColor/bg/subsup/underline were
4767/// silently dropped when combined with a code mark).
4768fn render_code_marked_text(text: &str, marks: &[AdfMark], output: &mut String) {
4769    let link_mark = marks.iter().find(|m| m.mark_type == "link");
4770
4771    let mut code_str = String::new();
4772    if let Some(link_mark) = link_mark {
4773        let href = link_href(link_mark);
4774        code_str.push('[');
4775        render_inline_code(text, &mut code_str);
4776        code_str.push_str("](");
4777        code_str.push_str(href);
4778        code_str.push(')');
4779    } else {
4780        render_inline_code(text, &mut code_str);
4781    }
4782
4783    // Build wrappers (outermost first) for span-attr and bracketed-span runs,
4784    // walking marks in order so the round-trip preserves mark ordering.
4785    let mut wrappers: Vec<(String, String)> = Vec::new();
4786    let mut i = 0;
4787    while i < marks.len() {
4788        match marks[i].mark_type.as_str() {
4789            "textColor" | "backgroundColor" | "subsup" => {
4790                let start = i;
4791                while i < marks.len() && is_span_attr_mark(&marks[i].mark_type) {
4792                    i += 1;
4793                }
4794                emit_span_attr_wrappers(&marks[start..i], &mut wrappers);
4795            }
4796            "underline" | "annotation" => {
4797                let start = i;
4798                while i < marks.len() && is_bracketed_span_mark(&marks[i].mark_type) {
4799                    i += 1;
4800                }
4801                emit_bracketed_wrappers(&marks[start..i], &mut wrappers);
4802            }
4803            _ => {
4804                i += 1;
4805            }
4806        }
4807    }
4808
4809    // Apply wrappers from innermost (last) to outermost (first).
4810    let mut result = code_str;
4811    for (open, close) in wrappers.iter().rev() {
4812        result.insert_str(0, open);
4813        result.push_str(close);
4814    }
4815    output.push_str(&result);
4816}
4817
4818/// Collects `:span` attribute fragments (color, bg, sub/sup) for a single mark.
4819fn collect_span_attr(mark: &AdfMark, attrs: &mut Vec<String>) {
4820    match mark.mark_type.as_str() {
4821        "textColor" => {
4822            if let Some(c) = mark
4823                .attrs
4824                .as_ref()
4825                .and_then(|a| a.get("color"))
4826                .and_then(serde_json::Value::as_str)
4827            {
4828                attrs.push(format!("color={c}"));
4829            }
4830        }
4831        "backgroundColor" => {
4832            if let Some(c) = mark
4833                .attrs
4834                .as_ref()
4835                .and_then(|a| a.get("color"))
4836                .and_then(serde_json::Value::as_str)
4837            {
4838                attrs.push(format!("bg={c}"));
4839            }
4840        }
4841        "subsup" => {
4842            if let Some(kind) = mark
4843                .attrs
4844                .as_ref()
4845                .and_then(|a| a.get("type"))
4846                .and_then(serde_json::Value::as_str)
4847            {
4848                attrs.push(kind.to_string());
4849            }
4850        }
4851        _ => {}
4852    }
4853}
4854
4855/// Collects bracketed-span attribute fragments for an `underline` or `annotation` mark.
4856fn collect_bracketed_attr(mark: &AdfMark, attrs: &mut Vec<String>) {
4857    match mark.mark_type.as_str() {
4858        "underline" => attrs.push("underline".to_string()),
4859        "annotation" => {
4860            if let Some(ref a) = mark.attrs {
4861                if let Some(id) = a.get("id").and_then(serde_json::Value::as_str) {
4862                    let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4863                    attrs.push(format!("annotation-id=\"{escaped}\""));
4864                }
4865                if let Some(at) = a.get("annotationType").and_then(serde_json::Value::as_str) {
4866                    attrs.push(format!("annotation-type={at}"));
4867                }
4868            }
4869        }
4870        _ => {}
4871    }
4872}
4873
4874fn is_span_attr_mark(mark_type: &str) -> bool {
4875    matches!(mark_type, "textColor" | "backgroundColor" | "subsup")
4876}
4877
4878fn is_bracketed_span_mark(mark_type: &str) -> bool {
4879    matches!(mark_type, "underline" | "annotation")
4880}
4881
4882/// Canonical ordering for span-attr marks, matching the order in which the
4883/// `:span` directive parser reads attributes (`color`, then `bg`, then
4884/// `sub`/`sup`).
4885fn span_attr_order(mark_type: &str) -> u8 {
4886    match mark_type {
4887        "textColor" => 0,
4888        "backgroundColor" => 1,
4889        "subsup" => 2,
4890        _ => u8::MAX,
4891    }
4892}
4893
4894/// Returns `true` if the run of span-attr marks is in the canonical order the
4895/// `:span` parser would produce.  A canonical run can be merged into one
4896/// `:span[...]{...}` wrapper; a non-canonical run must be split into one
4897/// nested wrapper per mark so the ordering survives the round-trip.
4898fn span_run_is_canonical(run: &[AdfMark]) -> bool {
4899    let mut prev = 0;
4900    for m in run {
4901        let order = span_attr_order(&m.mark_type);
4902        if order == u8::MAX || order < prev {
4903            return false;
4904        }
4905        prev = order;
4906    }
4907    true
4908}
4909
4910/// Returns `true` if the run of `underline`/`annotation` marks is in the
4911/// canonical order the bracketed-span parser produces (`underline` first,
4912/// followed by annotations).  A canonical run can be merged into one
4913/// `[...]{underline annotation-id=...}` wrapper.
4914fn bracketed_run_is_canonical(run: &[AdfMark]) -> bool {
4915    let mut seen_annotation = false;
4916    for m in run {
4917        match m.mark_type.as_str() {
4918            "underline" => {
4919                if seen_annotation {
4920                    return false;
4921                }
4922            }
4923            "annotation" => seen_annotation = true,
4924            _ => return false,
4925        }
4926    }
4927    true
4928}
4929
4930/// Emits one or more `:span[...]{...}` wrappers for a run of span-attr marks.
4931/// Canonical-order runs collapse into a single wrapper; non-canonical runs
4932/// emit one wrapper per mark so the order round-trips.
4933fn emit_span_attr_wrappers(run: &[AdfMark], wrappers: &mut Vec<(String, String)>) {
4934    if span_run_is_canonical(run) {
4935        let mut attrs = Vec::new();
4936        for m in run {
4937            collect_span_attr(m, &mut attrs);
4938        }
4939        wrappers.push((":span[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4940        return;
4941    }
4942    for m in run {
4943        let mut attrs = Vec::new();
4944        collect_span_attr(m, &mut attrs);
4945        wrappers.push((":span[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4946    }
4947}
4948
4949/// Emits one or more `[...]{...}` wrappers for a run of `underline`/`annotation`
4950/// marks.  Canonical-order runs collapse into a single wrapper; non-canonical
4951/// runs emit one wrapper per mark so the order round-trips.
4952fn emit_bracketed_wrappers(run: &[AdfMark], wrappers: &mut Vec<(String, String)>) {
4953    if bracketed_run_is_canonical(run) {
4954        let mut attrs = Vec::new();
4955        for m in run {
4956            collect_bracketed_attr(m, &mut attrs);
4957        }
4958        wrappers.push(("[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4959        return;
4960    }
4961    for m in run {
4962        let mut attrs = Vec::new();
4963        collect_bracketed_attr(m, &mut attrs);
4964        wrappers.push(("[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4965    }
4966}
4967
4968/// Extracts the href from a link mark.
4969fn link_href(mark: &AdfMark) -> &str {
4970    mark.attrs
4971        .as_ref()
4972        .and_then(|a| a.get("href"))
4973        .and_then(serde_json::Value::as_str)
4974        .unwrap_or("")
4975}
4976
4977#[cfg(test)]
4978#[allow(
4979    clippy::unwrap_used,
4980    clippy::expect_used,
4981    clippy::needless_update,
4982    clippy::needless_collect,
4983    duplicate_macro_attributes
4984)]
4985mod tests {
4986    use super::*;
4987
4988    // ── adf_to_plain_text tests ─────────────────────────────────────
4989
4990    #[test]
4991    fn adf_to_plain_text_single_paragraph() {
4992        let doc = markdown_to_adf("Hello world").unwrap();
4993        assert_eq!(adf_to_plain_text(&doc), "Hello world");
4994    }
4995
4996    #[test]
4997    fn adf_to_plain_text_multiple_paragraphs_space_separated() {
4998        let doc = markdown_to_adf("Alpha\n\nBeta").unwrap();
4999        let plain = adf_to_plain_text(&doc);
5000        // Blocks are space-separated so multi-paragraph anchor selections match.
5001        assert!(plain.contains("Alpha"));
5002        assert!(plain.contains("Beta"));
5003        assert_eq!(plain, "Alpha Beta");
5004    }
5005
5006    #[test]
5007    fn adf_to_plain_text_drops_marks_but_keeps_text() {
5008        let doc = markdown_to_adf("Hello **bold** world").unwrap();
5009        assert_eq!(adf_to_plain_text(&doc), "Hello bold world");
5010    }
5011
5012    #[test]
5013    fn adf_to_plain_text_empty_doc() {
5014        let doc = AdfDocument::new();
5015        assert_eq!(adf_to_plain_text(&doc), "");
5016    }
5017
5018    #[test]
5019    fn adf_to_plain_text_leading_empty_block_emits_no_extra_space() {
5020        // An empty paragraph followed by a text-bearing one must not produce
5021        // a leading space — the separator logic skips when `out` is still empty.
5022        let doc = AdfDocument {
5023            version: 1,
5024            doc_type: "doc".to_string(),
5025            content: vec![
5026                AdfNode {
5027                    node_type: "paragraph".to_string(),
5028                    attrs: None,
5029                    content: Some(vec![]),
5030                    text: None,
5031                    marks: None,
5032                    local_id: None,
5033                    parameters: None,
5034                },
5035                AdfNode {
5036                    node_type: "paragraph".to_string(),
5037                    attrs: None,
5038                    content: Some(vec![AdfNode::text("Hello")]),
5039                    text: None,
5040                    marks: None,
5041                    local_id: None,
5042                    parameters: None,
5043                },
5044            ],
5045        };
5046        assert_eq!(adf_to_plain_text(&doc), "Hello");
5047    }
5048
5049    // ── markdown_to_adf tests ───────────────────────────────────────
5050
5051    #[test]
5052    fn paragraph() {
5053        let doc = markdown_to_adf("Hello world").unwrap();
5054        assert_eq!(doc.content.len(), 1);
5055        assert_eq!(doc.content[0].node_type, "paragraph");
5056    }
5057
5058    #[test]
5059    fn heading_levels() {
5060        for level in 1..=6 {
5061            let hashes = "#".repeat(level);
5062            let md = format!("{hashes} Title");
5063            let doc = markdown_to_adf(&md).unwrap();
5064            assert_eq!(doc.content[0].node_type, "heading");
5065            let attrs = doc.content[0].attrs.as_ref().unwrap();
5066            assert_eq!(attrs["level"], level as u64);
5067        }
5068    }
5069
5070    // ── issue #1005: inline `code` is not permitted on headings ─────
5071
5072    #[test]
5073    fn heading_inline_code_mark_stripped() {
5074        // `### `GET /api`` parses an inline-code span; ADF forbids the `code`
5075        // mark on headings, so it is stripped and the text kept as plain.
5076        let doc = markdown_to_adf("### `GET /api`").unwrap();
5077        let heading = &doc.content[0];
5078        assert_eq!(heading.node_type, "heading");
5079        let content = heading.content.as_ref().unwrap();
5080        assert_eq!(content.len(), 1);
5081        assert_eq!(content[0].text.as_deref(), Some("GET /api"));
5082        assert!(
5083            content[0].marks.is_none(),
5084            "expected no marks, got: {:?}",
5085            content[0].marks
5086        );
5087    }
5088
5089    #[test]
5090    fn heading_code_strip_preserves_sibling_strong() {
5091        // `### **`x`**` → text `x` carrying `strong`; only `code` is removed.
5092        let doc = markdown_to_adf("### **`x`**").unwrap();
5093        let content = doc.content[0].content.as_ref().unwrap();
5094        let marks = content[0].marks.as_ref().unwrap();
5095        assert!(marks.iter().any(|m| m.mark_type == "strong"));
5096        assert!(!marks.iter().any(|m| m.mark_type == "code"));
5097    }
5098
5099    #[test]
5100    fn heading_code_strip_preserves_link() {
5101        // `### [`x`](url)` → text `x` carrying `link`; only `code` is removed.
5102        let doc = markdown_to_adf("### [`x`](https://e.com)").unwrap();
5103        let content = doc.content[0].content.as_ref().unwrap();
5104        let marks = content[0].marks.as_ref().unwrap();
5105        assert!(marks.iter().any(|m| m.mark_type == "link"));
5106        assert!(!marks.iter().any(|m| m.mark_type == "code"));
5107    }
5108
5109    #[test]
5110    fn heading_with_code_now_passes_validation() {
5111        // End-to-end guard: the converted document no longer trips the ADF
5112        // mark validator that previously rejected it at write time.
5113        let doc = markdown_to_adf("### `GET /api`").unwrap();
5114        let violations = crate::atlassian::adf_schema::validate_document(&doc);
5115        assert!(violations.is_empty(), "got: {violations:?}");
5116    }
5117
5118    #[test]
5119    fn paragraph_inline_code_mark_preserved() {
5120        // Regression guard: the strip is heading-only — paragraph inline code
5121        // keeps its `code` mark.
5122        let doc = markdown_to_adf("`x`").unwrap();
5123        let content = doc.content[0].content.as_ref().unwrap();
5124        let marks = content[0].marks.as_ref().unwrap();
5125        assert!(marks.iter().any(|m| m.mark_type == "code"));
5126    }
5127
5128    #[test]
5129    fn code_block() {
5130        let md = "```rust\nfn main() {}\n```";
5131        let doc = markdown_to_adf(md).unwrap();
5132        assert_eq!(doc.content[0].node_type, "codeBlock");
5133        let attrs = doc.content[0].attrs.as_ref().unwrap();
5134        assert_eq!(attrs["language"], "rust");
5135    }
5136
5137    #[test]
5138    fn code_block_no_language() {
5139        let md = "```\nsome code\n```";
5140        let doc = markdown_to_adf(md).unwrap();
5141        assert_eq!(doc.content[0].node_type, "codeBlock");
5142        assert!(doc.content[0].attrs.is_none());
5143    }
5144
5145    #[test]
5146    fn code_block_empty_language() {
5147        let md = "```\"\"\nsome code\n```";
5148        let doc = markdown_to_adf(md).unwrap();
5149        assert_eq!(doc.content[0].node_type, "codeBlock");
5150        let attrs = doc.content[0].attrs.as_ref().unwrap();
5151        assert_eq!(attrs["language"], "");
5152    }
5153
5154    #[test]
5155    fn horizontal_rule() {
5156        let doc = markdown_to_adf("---").unwrap();
5157        assert_eq!(doc.content[0].node_type, "rule");
5158    }
5159
5160    #[test]
5161    fn horizontal_rule_stars() {
5162        let doc = markdown_to_adf("***").unwrap();
5163        assert_eq!(doc.content[0].node_type, "rule");
5164    }
5165
5166    #[test]
5167    fn blockquote() {
5168        let md = "> This is a quote\n> Second line";
5169        let doc = markdown_to_adf(md).unwrap();
5170        assert_eq!(doc.content[0].node_type, "blockquote");
5171    }
5172
5173    #[test]
5174    fn bullet_list() {
5175        let md = "- Item 1\n- Item 2\n- Item 3";
5176        let doc = markdown_to_adf(md).unwrap();
5177        assert_eq!(doc.content[0].node_type, "bulletList");
5178        let items = doc.content[0].content.as_ref().unwrap();
5179        assert_eq!(items.len(), 3);
5180    }
5181
5182    #[test]
5183    fn ordered_list() {
5184        let md = "1. First\n2. Second\n3. Third";
5185        let doc = markdown_to_adf(md).unwrap();
5186        assert_eq!(doc.content[0].node_type, "orderedList");
5187        let items = doc.content[0].content.as_ref().unwrap();
5188        assert_eq!(items.len(), 3);
5189    }
5190
5191    #[test]
5192    fn task_list() {
5193        let md = "- [ ] Todo item\n- [x] Done item";
5194        let doc = markdown_to_adf(md).unwrap();
5195        assert_eq!(doc.content[0].node_type, "taskList");
5196        let items = doc.content[0].content.as_ref().unwrap();
5197        assert_eq!(items.len(), 2);
5198        assert_eq!(items[0].node_type, "taskItem");
5199        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5200        assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5201    }
5202
5203    #[test]
5204    fn task_list_uppercase_x() {
5205        let md = "- [X] Done item";
5206        let doc = markdown_to_adf(md).unwrap();
5207        assert_eq!(doc.content[0].node_type, "taskList");
5208        let item = &doc.content[0].content.as_ref().unwrap()[0];
5209        assert_eq!(item.attrs.as_ref().unwrap()["state"], "DONE");
5210    }
5211
5212    /// Issue #548: an empty task marker (no trailing space) must still be
5213    /// parsed as a `taskList` rather than a `bulletList` with `[ ]` text.
5214    #[test]
5215    fn task_list_empty_todo_no_trailing_space() {
5216        let md = "- [ ]";
5217        let doc = markdown_to_adf(md).unwrap();
5218        assert_eq!(doc.content[0].node_type, "taskList");
5219        let items = doc.content[0].content.as_ref().unwrap();
5220        assert_eq!(items.len(), 1);
5221        assert_eq!(items[0].node_type, "taskItem");
5222        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5223        assert!(items[0].content.is_none());
5224    }
5225
5226    /// Issue #548: likewise for a done checkbox with no body.
5227    #[test]
5228    fn task_list_empty_done_no_trailing_space() {
5229        let md = "- [x]\n- [X]";
5230        let doc = markdown_to_adf(md).unwrap();
5231        assert_eq!(doc.content[0].node_type, "taskList");
5232        let items = doc.content[0].content.as_ref().unwrap();
5233        assert_eq!(items.len(), 2);
5234        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DONE");
5235        assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5236    }
5237
5238    /// Issue #548: the body of `- [ ] text` must not have a spurious leading
5239    /// space introduced by relaxing the trailing-space requirement.
5240    #[test]
5241    fn task_list_body_has_no_leading_space() {
5242        let md = "- [ ] Buy groceries";
5243        let doc = markdown_to_adf(md).unwrap();
5244        let item = &doc.content[0].content.as_ref().unwrap()[0];
5245        let text = item.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5246        assert_eq!(text, "Buy groceries");
5247    }
5248
5249    /// Issue #548: round-trip from ADF with empty taskItems should preserve
5250    /// the `taskList` structure even if trailing spaces are stripped from the
5251    /// intermediate markdown (as many editors do).
5252    #[test]
5253    fn round_trip_empty_task_items_stripped_trailing_spaces() {
5254        let json = r#"{
5255            "version": 1,
5256            "type": "doc",
5257            "content": [{
5258                "type": "taskList",
5259                "attrs": {"localId": "abc"},
5260                "content": [
5261                    {"type": "taskItem", "attrs": {"localId": "def", "state": "TODO"}},
5262                    {"type": "taskItem", "attrs": {"localId": "ghi", "state": "DONE"}}
5263                ]
5264            }]
5265        }"#;
5266        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5267        let md = adf_to_markdown(&doc).unwrap();
5268        let stripped: String = md.lines().map(str::trim_end).collect::<Vec<_>>().join("\n");
5269        let parsed = markdown_to_adf(&stripped).unwrap();
5270        assert_eq!(parsed.content[0].node_type, "taskList");
5271        let items = parsed.content[0].content.as_ref().unwrap();
5272        assert_eq!(items.len(), 2);
5273        assert_eq!(items[0].node_type, "taskItem");
5274        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5275        assert_eq!(items[1].node_type, "taskItem");
5276        assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5277    }
5278
5279    #[test]
5280    fn try_parse_task_marker_accepts_bare_checkbox() {
5281        assert_eq!(try_parse_task_marker("[ ]"), Some(("TODO", "")));
5282        assert_eq!(try_parse_task_marker("[x]"), Some(("DONE", "")));
5283        assert_eq!(try_parse_task_marker("[X]"), Some(("DONE", "")));
5284        assert_eq!(try_parse_task_marker("[ ] foo"), Some(("TODO", "foo")));
5285        assert_eq!(try_parse_task_marker("[x] foo"), Some(("DONE", "foo")));
5286        assert_eq!(try_parse_task_marker("[ ]foo"), None);
5287        assert_eq!(try_parse_task_marker("[x]foo"), None);
5288        assert_eq!(try_parse_task_marker("[y] foo"), None);
5289    }
5290
5291    #[test]
5292    fn starts_with_task_marker_matches_parser() {
5293        // Anything `try_parse_task_marker` recognises must also be flagged
5294        // here so the renderer escapes it.
5295        assert!(starts_with_task_marker("[ ]"));
5296        assert!(starts_with_task_marker("[x]"));
5297        assert!(starts_with_task_marker("[X]"));
5298        assert!(starts_with_task_marker("[ ] foo"));
5299        assert!(starts_with_task_marker("[x] foo\n"));
5300        assert!(starts_with_task_marker("[ ]\n"));
5301        // No collision when the bracket is followed by non-whitespace.
5302        assert!(!starts_with_task_marker("[ ]foo"));
5303        assert!(!starts_with_task_marker("[y] foo"));
5304        assert!(!starts_with_task_marker("foo [ ] bar"));
5305        assert!(!starts_with_task_marker(""));
5306    }
5307
5308    /// Issue #548: a `bulletList` whose item starts with literal `[ ]` text
5309    /// must round-trip through markdown without being promoted to a
5310    /// `taskList`.
5311    #[test]
5312    fn round_trip_bullet_list_with_literal_checkbox_text() {
5313        let json = r#"{
5314            "version": 1,
5315            "type": "doc",
5316            "content": [{
5317                "type": "bulletList",
5318                "content": [{
5319                    "type": "listItem",
5320                    "content": [{
5321                        "type": "paragraph",
5322                        "content": [
5323                            {"type": "text", "text": "[ ] Review the "},
5324                            {"type": "text", "text": "config.yaml", "marks": [{"type": "code"}]},
5325                            {"type": "text", "text": " file"}
5326                        ]
5327                    }]
5328                }]
5329            }]
5330        }"#;
5331        let original: AdfDocument = serde_json::from_str(json).unwrap();
5332        let md = adf_to_markdown(&original).unwrap();
5333        // Renderer must escape the leading bracket.
5334        assert!(
5335            md.contains(r"- \[ ] Review the "),
5336            "rendered markdown: {md:?}"
5337        );
5338        let parsed = markdown_to_adf(&md).unwrap();
5339        assert_eq!(parsed.content[0].node_type, "bulletList");
5340        let item = &parsed.content[0].content.as_ref().unwrap()[0];
5341        assert_eq!(item.node_type, "listItem");
5342        let para = &item.content.as_ref().unwrap()[0];
5343        assert_eq!(para.node_type, "paragraph");
5344        let text_nodes = para.content.as_ref().unwrap();
5345        assert_eq!(text_nodes[0].text.as_deref().unwrap(), "[ ] Review the ");
5346        assert_eq!(text_nodes[1].text.as_deref().unwrap(), "config.yaml");
5347        assert_eq!(text_nodes[2].text.as_deref().unwrap(), " file");
5348    }
5349
5350    /// Issue #548: the same problem with a `[x]` marker.
5351    #[test]
5352    fn round_trip_bullet_list_with_literal_done_checkbox_text() {
5353        let json = r#"{
5354            "version": 1,
5355            "type": "doc",
5356            "content": [{
5357                "type": "bulletList",
5358                "content": [{
5359                    "type": "listItem",
5360                    "content": [{
5361                        "type": "paragraph",
5362                        "content": [{"type": "text", "text": "[x] not actually done"}]
5363                    }]
5364                }]
5365            }]
5366        }"#;
5367        let original: AdfDocument = serde_json::from_str(json).unwrap();
5368        let md = adf_to_markdown(&original).unwrap();
5369        assert!(md.contains(r"- \[x] "), "rendered markdown: {md:?}");
5370        let parsed = markdown_to_adf(&md).unwrap();
5371        assert_eq!(parsed.content[0].node_type, "bulletList");
5372        let item = &parsed.content[0].content.as_ref().unwrap()[0];
5373        let para = &item.content.as_ref().unwrap()[0];
5374        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5375        assert_eq!(text, "[x] not actually done");
5376    }
5377
5378    /// Issue #548: `bulletList` item whose entire content is literal `[ ]`.
5379    #[test]
5380    fn round_trip_bullet_list_with_bare_literal_checkbox() {
5381        let json = r#"{
5382            "version": 1,
5383            "type": "doc",
5384            "content": [{
5385                "type": "bulletList",
5386                "content": [{
5387                    "type": "listItem",
5388                    "content": [{
5389                        "type": "paragraph",
5390                        "content": [{"type": "text", "text": "[ ]"}]
5391                    }]
5392                }]
5393            }]
5394        }"#;
5395        let original: AdfDocument = serde_json::from_str(json).unwrap();
5396        let md = adf_to_markdown(&original).unwrap();
5397        let parsed = markdown_to_adf(&md).unwrap();
5398        assert_eq!(parsed.content[0].node_type, "bulletList");
5399        let item = &parsed.content[0].content.as_ref().unwrap()[0];
5400        let para = &item.content.as_ref().unwrap()[0];
5401        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5402        assert_eq!(text, "[ ]");
5403    }
5404
5405    /// Issue #548: a `bulletList` with a non-task `[?]` prefix should not be
5406    /// escaped — that would just produce noise.
5407    #[test]
5408    fn bullet_list_non_task_bracket_text_not_escaped() {
5409        let json = r#"{
5410            "version": 1,
5411            "type": "doc",
5412            "content": [{
5413                "type": "bulletList",
5414                "content": [{
5415                    "type": "listItem",
5416                    "content": [{
5417                        "type": "paragraph",
5418                        "content": [{"type": "text", "text": "[?] unsure"}]
5419                    }]
5420                }]
5421            }]
5422        }"#;
5423        let original: AdfDocument = serde_json::from_str(json).unwrap();
5424        let md = adf_to_markdown(&original).unwrap();
5425        assert!(!md.contains(r"\["), "should not escape: {md:?}");
5426        assert!(md.contains("- [?] unsure"), "rendered: {md:?}");
5427    }
5428
5429    /// Issue #548: nested `bulletList` items inside another `bulletList`
5430    /// must also have their literal `[ ]` text escaped.
5431    #[test]
5432    fn round_trip_nested_bullet_list_with_literal_checkbox_text() {
5433        let json = r#"{
5434            "version": 1,
5435            "type": "doc",
5436            "content": [{
5437                "type": "bulletList",
5438                "content": [{
5439                    "type": "listItem",
5440                    "content": [
5441                        {"type": "paragraph", "content": [{"type": "text", "text": "outer"}]},
5442                        {"type": "bulletList", "content": [{
5443                            "type": "listItem",
5444                            "content": [{
5445                                "type": "paragraph",
5446                                "content": [{"type": "text", "text": "[ ] inner literal"}]
5447                            }]
5448                        }]}
5449                    ]
5450                }]
5451            }]
5452        }"#;
5453        let original: AdfDocument = serde_json::from_str(json).unwrap();
5454        let md = adf_to_markdown(&original).unwrap();
5455        let parsed = markdown_to_adf(&md).unwrap();
5456        let outer = &parsed.content[0];
5457        assert_eq!(outer.node_type, "bulletList");
5458        let outer_item = &outer.content.as_ref().unwrap()[0];
5459        let inner_list = &outer_item.content.as_ref().unwrap()[1];
5460        assert_eq!(inner_list.node_type, "bulletList");
5461        let inner_item = &inner_list.content.as_ref().unwrap()[0];
5462        assert_eq!(inner_item.node_type, "listItem");
5463        let para = &inner_item.content.as_ref().unwrap()[0];
5464        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5465        assert_eq!(text, "[ ] inner literal");
5466    }
5467
5468    #[test]
5469    fn adf_task_list_to_markdown() {
5470        let doc = AdfDocument {
5471            version: 1,
5472            doc_type: "doc".to_string(),
5473            content: vec![AdfNode::task_list(vec![
5474                AdfNode::task_item(
5475                    "TODO",
5476                    vec![AdfNode::paragraph(vec![AdfNode::text("Todo")])],
5477                ),
5478                AdfNode::task_item(
5479                    "DONE",
5480                    vec![AdfNode::paragraph(vec![AdfNode::text("Done")])],
5481                ),
5482            ])],
5483        };
5484        let md = adf_to_markdown(&doc).unwrap();
5485        assert!(md.contains("- [ ] Todo"));
5486        assert!(md.contains("- [x] Done"));
5487    }
5488
5489    #[test]
5490    fn round_trip_task_list() {
5491        let md = "- [ ] Todo item\n- [x] Done item\n";
5492        let doc = markdown_to_adf(md).unwrap();
5493        let result = adf_to_markdown(&doc).unwrap();
5494        assert!(result.contains("- [ ] Todo item"));
5495        assert!(result.contains("- [x] Done item"));
5496    }
5497
5498    /// Issue #408: taskItem content with inline nodes directly (no paragraph wrapper).
5499    #[test]
5500    fn adf_task_item_unwrapped_inline_content() {
5501        // Real Confluence ADF: taskItem contains text nodes directly, no paragraph.
5502        let json = r#"{
5503            "version": 1,
5504            "type": "doc",
5505            "content": [{
5506                "type": "taskList",
5507                "attrs": {"localId": "list-001"},
5508                "content": [{
5509                    "type": "taskItem",
5510                    "attrs": {"localId": "task-001", "state": "TODO"},
5511                    "content": [{"type": "text", "text": "Do something"}]
5512                }]
5513            }]
5514        }"#;
5515        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5516        let md = adf_to_markdown(&doc).unwrap();
5517        assert!(md.contains("- [ ] Do something"), "got: {md}");
5518        assert!(!md.contains("adf-unsupported"), "got: {md}");
5519    }
5520
5521    /// Issue #408: multiple taskItems with unwrapped inline content.
5522    #[test]
5523    fn adf_task_list_multiple_unwrapped_items() {
5524        let json = r#"{
5525            "version": 1,
5526            "type": "doc",
5527            "content": [{
5528                "type": "taskList",
5529                "attrs": {"localId": "list-001"},
5530                "content": [
5531                    {
5532                        "type": "taskItem",
5533                        "attrs": {"localId": "task-001", "state": "TODO"},
5534                        "content": [{"type": "text", "text": "First task"}]
5535                    },
5536                    {
5537                        "type": "taskItem",
5538                        "attrs": {"localId": "task-002", "state": "DONE"},
5539                        "content": [{"type": "text", "text": "Second task"}]
5540                    }
5541                ]
5542            }]
5543        }"#;
5544        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5545        let md = adf_to_markdown(&doc).unwrap();
5546        assert!(md.contains("- [ ] First task"), "got: {md}");
5547        assert!(md.contains("- [x] Second task"), "got: {md}");
5548        assert!(!md.contains("adf-unsupported"), "got: {md}");
5549    }
5550
5551    /// Issue #408: unwrapped inline content with marks (bold text).
5552    #[test]
5553    fn adf_task_item_unwrapped_inline_with_marks() {
5554        let json = r#"{
5555            "version": 1,
5556            "type": "doc",
5557            "content": [{
5558                "type": "taskList",
5559                "attrs": {"localId": "list-001"},
5560                "content": [{
5561                    "type": "taskItem",
5562                    "attrs": {"localId": "task-001", "state": "TODO"},
5563                    "content": [
5564                        {"type": "text", "text": "Buy "},
5565                        {"type": "text", "text": "groceries", "marks": [{"type": "strong"}]},
5566                        {"type": "text", "text": " today"}
5567                    ]
5568                }]
5569            }]
5570        }"#;
5571        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5572        let md = adf_to_markdown(&doc).unwrap();
5573        assert!(md.contains("- [ ] Buy **groceries** today"), "got: {md}");
5574    }
5575
5576    /// Issue #408: taskItem localId is preserved for unwrapped inline content.
5577    #[test]
5578    fn adf_task_item_unwrapped_preserves_local_id() {
5579        let json = r#"{
5580            "version": 1,
5581            "type": "doc",
5582            "content": [{
5583                "type": "taskList",
5584                "attrs": {"localId": "list-001"},
5585                "content": [{
5586                    "type": "taskItem",
5587                    "attrs": {"localId": "task-001", "state": "TODO"},
5588                    "content": [{"type": "text", "text": "Do something"}]
5589                }]
5590            }]
5591        }"#;
5592        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5593        let md = adf_to_markdown(&doc).unwrap();
5594        assert!(md.contains("{localId=task-001}"), "got: {md}");
5595        assert!(md.contains("{localId=list-001}"), "got: {md}");
5596    }
5597
5598    /// Issue #408: round-trip from Confluence ADF with unwrapped taskItem content.
5599    #[test]
5600    fn round_trip_task_list_unwrapped_inline() {
5601        let json = r#"{
5602            "version": 1,
5603            "type": "doc",
5604            "content": [{
5605                "type": "taskList",
5606                "attrs": {"localId": "list-001"},
5607                "content": [
5608                    {
5609                        "type": "taskItem",
5610                        "attrs": {"localId": "task-001", "state": "TODO"},
5611                        "content": [{"type": "text", "text": "Do something"}]
5612                    },
5613                    {
5614                        "type": "taskItem",
5615                        "attrs": {"localId": "task-002", "state": "DONE"},
5616                        "content": [{"type": "text", "text": "Already done"}]
5617                    }
5618                ]
5619            }]
5620        }"#;
5621        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5622        let md = adf_to_markdown(&doc).unwrap();
5623
5624        // Round-trip: markdown back to ADF
5625        let doc2 = markdown_to_adf(&md).unwrap();
5626        assert_eq!(doc2.content[0].node_type, "taskList");
5627
5628        let items = doc2.content[0].content.as_ref().unwrap();
5629        assert_eq!(items.len(), 2);
5630        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5631        assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5632
5633        // localIds preserved
5634        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "task-001");
5635        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "task-002");
5636        assert_eq!(
5637            doc2.content[0].attrs.as_ref().unwrap()["localId"],
5638            "list-001"
5639        );
5640    }
5641
5642    /// Issue #408: taskItem with inline content followed by a nested block (sub-list).
5643    #[test]
5644    fn adf_task_item_unwrapped_inline_then_block() {
5645        let json = r#"{
5646            "version": 1,
5647            "type": "doc",
5648            "content": [{
5649                "type": "taskList",
5650                "attrs": {"localId": "list-001"},
5651                "content": [{
5652                    "type": "taskItem",
5653                    "attrs": {"localId": "task-001", "state": "TODO"},
5654                    "content": [
5655                        {"type": "text", "text": "Parent task"},
5656                        {
5657                            "type": "bulletList",
5658                            "content": [{
5659                                "type": "listItem",
5660                                "content": [{
5661                                    "type": "paragraph",
5662                                    "content": [{"type": "text", "text": "sub-item"}]
5663                                }]
5664                            }]
5665                        }
5666                    ]
5667                }]
5668            }]
5669        }"#;
5670        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5671        let md = adf_to_markdown(&doc).unwrap();
5672        assert!(md.contains("- [ ] Parent task"), "got: {md}");
5673        assert!(md.contains("  - sub-item"), "got: {md}");
5674        assert!(!md.contains("adf-unsupported"), "got: {md}");
5675    }
5676
5677    /// Issue #408: taskItem with empty content array renders without panic.
5678    #[test]
5679    fn adf_task_item_empty_content() {
5680        let json = r#"{
5681            "version": 1,
5682            "type": "doc",
5683            "content": [{
5684                "type": "taskList",
5685                "attrs": {"localId": "list-001"},
5686                "content": [{
5687                    "type": "taskItem",
5688                    "attrs": {"localId": "task-001", "state": "TODO"},
5689                    "content": []
5690                }]
5691            }]
5692        }"#;
5693        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5694        let md = adf_to_markdown(&doc).unwrap();
5695        assert!(md.contains("- [ ] "), "got: {md}");
5696        assert!(!md.contains("adf-unsupported"), "got: {md}");
5697    }
5698
5699    /// Issue #489: nested taskItem inside taskItem.content renders as indented
5700    /// task items instead of corrupting the surrounding taskList.
5701    #[test]
5702    fn adf_nested_task_item_renders_without_corruption() {
5703        let json = r#"{
5704            "type": "doc",
5705            "version": 1,
5706            "content": [{
5707                "type": "taskList",
5708                "attrs": {"localId": ""},
5709                "content": [
5710                    {
5711                        "type": "taskItem",
5712                        "attrs": {"localId": "aabbccdd-1234-5678-abcd-aabbccdd1234", "state": "TODO"},
5713                        "content": [{"type": "text", "text": "Normal task"}]
5714                    },
5715                    {
5716                        "type": "taskItem",
5717                        "attrs": {"localId": ""},
5718                        "content": [
5719                            {
5720                                "type": "taskItem",
5721                                "attrs": {"localId": "bbccddee-2345-6789-bcde-bbccddee2345", "state": "TODO"},
5722                                "content": [{"type": "text", "text": "Nested task one"}]
5723                            },
5724                            {
5725                                "type": "taskItem",
5726                                "attrs": {"localId": "ccddee11-3456-7890-cdef-ccddee113456", "state": "DONE"},
5727                                "content": [{"type": "text", "text": "Nested task two"}]
5728                            }
5729                        ]
5730                    }
5731                ]
5732            }]
5733        }"#;
5734        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5735        let md = adf_to_markdown(&doc).unwrap();
5736        // Normal task preserved
5737        assert!(md.contains("- [ ] Normal task"), "got: {md}");
5738        // Nested tasks rendered as indented task items, not adf-unsupported
5739        assert!(!md.contains("adf-unsupported"), "got: {md}");
5740        assert!(md.contains("  - [ ] Nested task one"), "got: {md}");
5741        assert!(md.contains("  - [x] Nested task two"), "got: {md}");
5742    }
5743
5744    /// Issue #489: round-trip of nested taskItem preserves data.
5745    #[test]
5746    fn round_trip_nested_task_item() {
5747        let json = r#"{
5748            "type": "doc",
5749            "version": 1,
5750            "content": [{
5751                "type": "taskList",
5752                "attrs": {"localId": ""},
5753                "content": [
5754                    {
5755                        "type": "taskItem",
5756                        "attrs": {"localId": "task-001", "state": "TODO"},
5757                        "content": [{"type": "text", "text": "Normal task"}]
5758                    },
5759                    {
5760                        "type": "taskItem",
5761                        "attrs": {"localId": ""},
5762                        "content": [
5763                            {
5764                                "type": "taskItem",
5765                                "attrs": {"localId": "task-002", "state": "TODO"},
5766                                "content": [{"type": "text", "text": "Nested one"}]
5767                            },
5768                            {
5769                                "type": "taskItem",
5770                                "attrs": {"localId": "task-003", "state": "DONE"},
5771                                "content": [{"type": "text", "text": "Nested two"}]
5772                            }
5773                        ]
5774                    }
5775                ]
5776            }]
5777        }"#;
5778        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5779        let md = adf_to_markdown(&doc).unwrap();
5780        let doc2 = markdown_to_adf(&md).unwrap();
5781
5782        // Top-level structure: taskList with 2 items
5783        assert_eq!(doc2.content[0].node_type, "taskList");
5784        let items = doc2.content[0].content.as_ref().unwrap();
5785        assert_eq!(items.len(), 2, "expected 2 top-level items, got: {items:?}");
5786
5787        // First item: normal task preserved
5788        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5789        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "task-001");
5790        let first_content = items[0].content.as_ref().unwrap();
5791        assert_eq!(first_content[0].text.as_deref(), Some("Normal task"));
5792
5793        // Second item: container taskItem — no spurious `state` attr
5794        let container = &items[1];
5795        assert_eq!(container.node_type, "taskItem");
5796        let c_attrs = container.attrs.as_ref().unwrap();
5797        assert!(
5798            c_attrs.get("state").is_none(),
5799            "container should have no state attr, got: {c_attrs:?}"
5800        );
5801
5802        // Children are bare taskItems, NOT wrapped in a taskList
5803        let container_content = container.content.as_ref().unwrap();
5804        assert_eq!(
5805            container_content.len(),
5806            2,
5807            "expected 2 bare taskItem children"
5808        );
5809        assert_eq!(container_content[0].node_type, "taskItem");
5810        assert_eq!(
5811            container_content[0].attrs.as_ref().unwrap()["state"],
5812            "TODO"
5813        );
5814        assert_eq!(
5815            container_content[0].attrs.as_ref().unwrap()["localId"],
5816            "task-002"
5817        );
5818        assert_eq!(container_content[1].node_type, "taskItem");
5819        assert_eq!(
5820            container_content[1].attrs.as_ref().unwrap()["state"],
5821            "DONE"
5822        );
5823        assert_eq!(
5824            container_content[1].attrs.as_ref().unwrap()["localId"],
5825            "task-003"
5826        );
5827    }
5828
5829    /// Issue #489: nested taskItem with localIds on both container and children.
5830    #[test]
5831    fn adf_nested_task_item_preserves_local_ids() {
5832        let json = r#"{
5833            "type": "doc",
5834            "version": 1,
5835            "content": [{
5836                "type": "taskList",
5837                "attrs": {"localId": "list-001"},
5838                "content": [{
5839                    "type": "taskItem",
5840                    "attrs": {"localId": "container-001", "state": "TODO"},
5841                    "content": [{
5842                        "type": "taskItem",
5843                        "attrs": {"localId": "child-001", "state": "DONE"},
5844                        "content": [{"type": "text", "text": "Nested child"}]
5845                    }]
5846                }]
5847            }]
5848        }"#;
5849        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5850        let md = adf_to_markdown(&doc).unwrap();
5851        // Container localId is emitted
5852        assert!(
5853            md.contains("localId=container-001"),
5854            "container localId missing: {md}"
5855        );
5856        // Child localId is emitted
5857        assert!(
5858            md.contains("localId=child-001"),
5859            "child localId missing: {md}"
5860        );
5861        assert!(!md.contains("adf-unsupported"), "got: {md}");
5862    }
5863
5864    /// Issue #489: nested taskItem content mixed with a non-taskItem block node.
5865    /// Covers the else branch in the renderer where a child is not a taskItem.
5866    #[test]
5867    fn adf_nested_task_item_mixed_with_block_node() {
5868        let json = r#"{
5869            "type": "doc",
5870            "version": 1,
5871            "content": [{
5872                "type": "taskList",
5873                "attrs": {"localId": ""},
5874                "content": [{
5875                    "type": "taskItem",
5876                    "attrs": {"localId": "", "state": "TODO"},
5877                    "content": [
5878                        {
5879                            "type": "taskItem",
5880                            "attrs": {"localId": "", "state": "TODO"},
5881                            "content": [{"type": "text", "text": "A nested task"}]
5882                        },
5883                        {
5884                            "type": "paragraph",
5885                            "content": [{"type": "text", "text": "Stray paragraph"}]
5886                        }
5887                    ]
5888                }]
5889            }]
5890        }"#;
5891        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5892        let md = adf_to_markdown(&doc).unwrap();
5893        assert!(md.contains("  - [ ] A nested task"), "got: {md}");
5894        assert!(md.contains("  Stray paragraph"), "got: {md}");
5895        assert!(!md.contains("adf-unsupported"), "got: {md}");
5896    }
5897
5898    /// Issue #489: task item with inline text AND indented sub-content.
5899    /// Covers the parser's `Some` branch when appending nested blocks to
5900    /// an existing content vec.
5901    #[test]
5902    fn task_item_with_text_and_nested_sub_content() {
5903        let md = "- [ ] Parent task\n  - [ ] Sub task\n";
5904        let doc = markdown_to_adf(md).unwrap();
5905        assert_eq!(doc.content[0].node_type, "taskList");
5906        let items = doc.content[0].content.as_ref().unwrap();
5907        // Issue #506: the nested taskList is a sibling of the taskItem,
5908        // not a child — matching ADF's canonical structure.
5909        assert_eq!(items.len(), 2, "got: {items:?}");
5910        let parent = &items[0];
5911        assert_eq!(parent.attrs.as_ref().unwrap()["state"], "TODO");
5912        let parent_content = parent.content.as_ref().unwrap();
5913        assert_eq!(parent_content[0].text.as_deref(), Some("Parent task"));
5914        // Second item: nested taskList (sibling)
5915        assert_eq!(items[1].node_type, "taskList");
5916        let nested = items[1].content.as_ref().unwrap();
5917        assert_eq!(nested.len(), 1);
5918        assert_eq!(nested[0].attrs.as_ref().unwrap()["state"], "TODO");
5919    }
5920
5921    /// Issue #489: empty task item with non-taskList sub-content (e.g. a
5922    /// paragraph).  Exercises the `None` branch when the sub-content does
5923    /// not qualify for container-unwrap.
5924    #[test]
5925    fn task_item_empty_with_non_tasklist_sub_content() {
5926        let md = "- [ ] \n  Some paragraph text\n";
5927        let doc = markdown_to_adf(md).unwrap();
5928        assert_eq!(doc.content[0].node_type, "taskList");
5929        let items = doc.content[0].content.as_ref().unwrap();
5930        assert_eq!(items.len(), 1);
5931        let item = &items[0];
5932        assert_eq!(item.attrs.as_ref().unwrap()["state"], "TODO");
5933        let content = item.content.as_ref().unwrap();
5934        // Sub-content is a paragraph (not unwrapped since it's not a taskList)
5935        assert_eq!(content[0].node_type, "paragraph");
5936    }
5937
5938    /// Issue #489: single nested taskItem (edge case — only one child).
5939    #[test]
5940    fn adf_nested_task_item_single_child() {
5941        let json = r#"{
5942            "type": "doc",
5943            "version": 1,
5944            "content": [{
5945                "type": "taskList",
5946                "attrs": {"localId": ""},
5947                "content": [{
5948                    "type": "taskItem",
5949                    "attrs": {"localId": "", "state": "TODO"},
5950                    "content": [{
5951                        "type": "taskItem",
5952                        "attrs": {"localId": "", "state": "DONE"},
5953                        "content": [{"type": "text", "text": "Only child"}]
5954                    }]
5955                }]
5956            }]
5957        }"#;
5958        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5959        let md = adf_to_markdown(&doc).unwrap();
5960        assert!(md.contains("  - [x] Only child"), "got: {md}");
5961        assert!(!md.contains("adf-unsupported"), "got: {md}");
5962    }
5963
5964    /// Issue #506: nested taskList as direct child of outer taskList is
5965    /// rendered indented so it round-trips back as taskList, not taskItem.
5966    #[test]
5967    fn adf_nested_tasklist_sibling_renders_indented() {
5968        let json = r#"{
5969            "version": 1,
5970            "type": "doc",
5971            "content": [{
5972                "type": "taskList",
5973                "attrs": {"localId": ""},
5974                "content": [
5975                    {
5976                        "type": "taskItem",
5977                        "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000001", "state": "TODO"},
5978                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task one"}]}]
5979                    },
5980                    {
5981                        "type": "taskList",
5982                        "attrs": {"localId": ""},
5983                        "content": [{
5984                            "type": "taskItem",
5985                            "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000002", "state": "TODO"},
5986                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "nested sub-task"}]}]
5987                        }]
5988                    },
5989                    {
5990                        "type": "taskItem",
5991                        "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000003", "state": "TODO"},
5992                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task two"}]}]
5993                    }
5994                ]
5995            }]
5996        }"#;
5997        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5998        let md = adf_to_markdown(&doc).unwrap();
5999        // The nested taskList should be indented under the preceding item.
6000        assert!(md.contains("- [ ] parent task one"), "got: {md}");
6001        assert!(md.contains("  - [ ] nested sub-task"), "got: {md}");
6002        assert!(md.contains("- [ ] parent task two"), "got: {md}");
6003    }
6004
6005    /// Issue #506: round-trip preserves nested taskList type.
6006    #[test]
6007    fn round_trip_nested_tasklist_preserves_type() {
6008        let json = r#"{
6009            "version": 1,
6010            "type": "doc",
6011            "content": [{
6012                "type": "taskList",
6013                "attrs": {"localId": ""},
6014                "content": [
6015                    {
6016                        "type": "taskItem",
6017                        "attrs": {"localId": "", "state": "TODO"},
6018                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task one"}]}]
6019                    },
6020                    {
6021                        "type": "taskList",
6022                        "attrs": {"localId": ""},
6023                        "content": [{
6024                            "type": "taskItem",
6025                            "attrs": {"localId": "", "state": "TODO"},
6026                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "nested sub-task"}]}]
6027                        }]
6028                    },
6029                    {
6030                        "type": "taskItem",
6031                        "attrs": {"localId": "", "state": "TODO"},
6032                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task two"}]}]
6033                    }
6034                ]
6035            }]
6036        }"#;
6037        let doc: AdfDocument = serde_json::from_str(json).unwrap();
6038        let md = adf_to_markdown(&doc).unwrap();
6039        let rt_doc = markdown_to_adf(&md).unwrap();
6040        // The outer taskList should still be present.
6041        assert_eq!(rt_doc.content[0].node_type, "taskList");
6042        let items = rt_doc.content[0].content.as_ref().unwrap();
6043        // The nested taskList is a sibling of the taskItem nodes,
6044        // matching the original ADF structure (issue #506).
6045        assert_eq!(items.len(), 3, "got: {items:?}");
6046        assert_eq!(items[0].node_type, "taskItem");
6047        assert_eq!(
6048            items[1].node_type, "taskList",
6049            "nested taskList should survive round-trip"
6050        );
6051        assert_eq!(items[2].node_type, "taskItem");
6052        let nested_items = items[1].content.as_ref().unwrap();
6053        assert_eq!(nested_items[0].attrs.as_ref().unwrap()["state"], "TODO");
6054    }
6055
6056    /// Issue #506: nested taskList with DONE state preserves checkbox.
6057    #[test]
6058    fn adf_nested_tasklist_done_state() {
6059        let json = r#"{
6060            "version": 1,
6061            "type": "doc",
6062            "content": [{
6063                "type": "taskList",
6064                "attrs": {"localId": ""},
6065                "content": [
6066                    {
6067                        "type": "taskItem",
6068                        "attrs": {"localId": "", "state": "TODO"},
6069                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent"}]}]
6070                    },
6071                    {
6072                        "type": "taskList",
6073                        "attrs": {"localId": ""},
6074                        "content": [{
6075                            "type": "taskItem",
6076                            "attrs": {"localId": "", "state": "DONE"},
6077                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "done child"}]}]
6078                        }]
6079                    }
6080                ]
6081            }]
6082        }"#;
6083        let doc: AdfDocument = serde_json::from_str(json).unwrap();
6084        let md = adf_to_markdown(&doc).unwrap();
6085        assert!(md.contains("  - [x] done child"), "got: {md}");
6086        // Round-trip preserves DONE state — nested taskList is a sibling.
6087        let rt_doc = markdown_to_adf(&md).unwrap();
6088        let items = rt_doc.content[0].content.as_ref().unwrap();
6089        assert_eq!(
6090            items[1].node_type, "taskList",
6091            "nested taskList should survive round-trip"
6092        );
6093        let nested_item = &items[1].content.as_ref().unwrap()[0];
6094        assert_eq!(nested_item.attrs.as_ref().unwrap()["state"], "DONE");
6095    }
6096
6097    /// Issue #506: multiple nested taskLists at the same level.
6098    #[test]
6099    fn adf_multiple_nested_tasklists() {
6100        let json = r#"{
6101            "version": 1,
6102            "type": "doc",
6103            "content": [{
6104                "type": "taskList",
6105                "attrs": {"localId": ""},
6106                "content": [
6107                    {
6108                        "type": "taskItem",
6109                        "attrs": {"localId": "", "state": "TODO"},
6110                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "first parent"}]}]
6111                    },
6112                    {
6113                        "type": "taskList",
6114                        "attrs": {"localId": ""},
6115                        "content": [{
6116                            "type": "taskItem",
6117                            "attrs": {"localId": "", "state": "TODO"},
6118                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child A"}]}]
6119                        }]
6120                    },
6121                    {
6122                        "type": "taskItem",
6123                        "attrs": {"localId": "", "state": "TODO"},
6124                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "second parent"}]}]
6125                    },
6126                    {
6127                        "type": "taskList",
6128                        "attrs": {"localId": ""},
6129                        "content": [{
6130                            "type": "taskItem",
6131                            "attrs": {"localId": "", "state": "DONE"},
6132                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child B"}]}]
6133                        }]
6134                    }
6135                ]
6136            }]
6137        }"#;
6138        let doc: AdfDocument = serde_json::from_str(json).unwrap();
6139        let md = adf_to_markdown(&doc).unwrap();
6140        assert!(md.contains("- [ ] first parent"), "got: {md}");
6141        assert!(md.contains("  - [ ] child A"), "got: {md}");
6142        assert!(md.contains("- [ ] second parent"), "got: {md}");
6143        assert!(md.contains("  - [x] child B"), "got: {md}");
6144    }
6145
6146    /// Issue #506: second round-trip is stable (idempotent after first
6147    /// structural normalisation).
6148    #[test]
6149    fn round_trip_nested_tasklist_stable() {
6150        let json = r#"{
6151            "version": 1,
6152            "type": "doc",
6153            "content": [{
6154                "type": "taskList",
6155                "attrs": {"localId": ""},
6156                "content": [
6157                    {
6158                        "type": "taskItem",
6159                        "attrs": {"localId": "", "state": "TODO"},
6160                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent"}]}]
6161                    },
6162                    {
6163                        "type": "taskList",
6164                        "attrs": {"localId": ""},
6165                        "content": [{
6166                            "type": "taskItem",
6167                            "attrs": {"localId": "", "state": "TODO"},
6168                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child"}]}]
6169                        }]
6170                    }
6171                ]
6172            }]
6173        }"#;
6174        let doc: AdfDocument = serde_json::from_str(json).unwrap();
6175        // First round-trip.
6176        let md1 = adf_to_markdown(&doc).unwrap();
6177        let rt1 = markdown_to_adf(&md1).unwrap();
6178        // Second round-trip.
6179        let md2 = adf_to_markdown(&rt1).unwrap();
6180        let rt2 = markdown_to_adf(&md2).unwrap();
6181        // Markdown output should be identical after first normalisation.
6182        assert_eq!(md1, md2, "markdown should be stable across round-trips");
6183        // ADF structure should also be stable.
6184        let rt1_json = serde_json::to_string(&rt1).unwrap();
6185        let rt2_json = serde_json::to_string(&rt2).unwrap();
6186        assert_eq!(
6187            rt1_json, rt2_json,
6188            "ADF should be stable across round-trips"
6189        );
6190    }
6191
6192    /// Issue #506: task item with text and mixed indented sub-content
6193    /// (taskList + non-taskList block).  Exercises the `child_nodes` branch
6194    /// where non-taskList blocks stay as children of the taskItem while
6195    /// taskLists are promoted to siblings.
6196    #[test]
6197    fn task_item_mixed_sub_content_splits_siblings() {
6198        let md = "- [ ] Parent task\n  - [ ] Sub task\n  Some paragraph\n";
6199        let doc = markdown_to_adf(md).unwrap();
6200        let items = doc.content[0].content.as_ref().unwrap();
6201        // taskItem + sibling taskList
6202        assert_eq!(items.len(), 2, "got: {items:?}");
6203        assert_eq!(items[0].node_type, "taskItem");
6204        let parent_content = items[0].content.as_ref().unwrap();
6205        // Inline text + paragraph block (the non-taskList sub-content)
6206        assert!(
6207            parent_content.iter().any(|n| n.node_type == "paragraph"),
6208            "non-taskList sub-content should stay as child: {parent_content:?}"
6209        );
6210        // Sibling taskList
6211        assert_eq!(items[1].node_type, "taskList");
6212    }
6213
6214    /// Issue #506: empty task item with mixed indented sub-content hits the
6215    /// `None` arm of the `task.content` match when promoting taskLists to
6216    /// siblings.
6217    #[test]
6218    fn empty_task_item_mixed_sub_content_none_arm() {
6219        let md = "- [ ] \n  Some paragraph\n  - [ ] Sub task\n";
6220        let doc = markdown_to_adf(md).unwrap();
6221        let items = doc.content[0].content.as_ref().unwrap();
6222        // taskItem (with paragraph child) + sibling taskList
6223        assert_eq!(items.len(), 2, "got: {items:?}");
6224        assert_eq!(items[0].node_type, "taskItem");
6225        let parent_content = items[0].content.as_ref().unwrap();
6226        assert!(
6227            parent_content.iter().any(|n| n.node_type == "paragraph"),
6228            "paragraph should be assigned to taskItem: {parent_content:?}"
6229        );
6230        assert_eq!(items[1].node_type, "taskList");
6231    }
6232
6233    /// Issue #506: task item with text and only non-taskList sub-content
6234    /// (no sibling taskLists).  Exercises the fall-through path where
6235    /// `sibling_task_lists` is empty and child_nodes are appended to
6236    /// the existing task content (Some arm).
6237    #[test]
6238    fn task_item_text_with_non_tasklist_sub_content_only() {
6239        let md = "- [ ] My task\n  Extra paragraph content\n";
6240        let doc = markdown_to_adf(md).unwrap();
6241        let items = doc.content[0].content.as_ref().unwrap();
6242        // Single taskItem — no sibling taskLists to extract.
6243        assert_eq!(items.len(), 1, "got: {items:?}");
6244        assert_eq!(items[0].node_type, "taskItem");
6245        let content = items[0].content.as_ref().unwrap();
6246        // Inline text + sub-paragraph
6247        assert!(
6248            content.iter().any(|n| n.node_type == "paragraph"),
6249            "paragraph sub-content should be a child of taskItem: {content:?}"
6250        );
6251    }
6252
6253    /// Covers the else branch in render_list_item_content where the first
6254    /// child of a list item is a block node (not paragraph, not inline).
6255    #[test]
6256    fn adf_list_item_leading_block_node() {
6257        let json = r#"{
6258            "version": 1,
6259            "type": "doc",
6260            "content": [{
6261                "type": "bulletList",
6262                "content": [{
6263                    "type": "listItem",
6264                    "content": [{
6265                        "type": "codeBlock",
6266                        "attrs": {"language": "rust"},
6267                        "content": [{"type": "text", "text": "let x = 1;"}]
6268                    }]
6269                }]
6270            }]
6271        }"#;
6272        let doc: AdfDocument = serde_json::from_str(json).unwrap();
6273        let md = adf_to_markdown(&doc).unwrap();
6274        assert!(md.contains("```rust"), "got: {md}");
6275        assert!(md.contains("let x = 1;"), "got: {md}");
6276        // Continuation lines must be indented so the block stays inside
6277        // the list item on round-trip (issue #511).
6278        for line in md.lines() {
6279            if line.starts_with("- ") {
6280                continue; // first line with list marker
6281            }
6282            if line.trim().is_empty() {
6283                continue;
6284            }
6285            assert!(
6286                line.starts_with("  "),
6287                "continuation line not indented: {line:?}"
6288            );
6289        }
6290    }
6291
6292    /// Round-trip a codeBlock inside a listItem whose content contains a
6293    /// backtick character — the exact reproducer from issue #511.
6294    #[test]
6295    fn code_block_in_list_item_backtick_roundtrip() {
6296        let json = r#"{
6297            "version": 1,
6298            "type": "doc",
6299            "content": [{
6300                "type": "bulletList",
6301                "content": [{
6302                    "type": "listItem",
6303                    "content": [{
6304                        "type": "codeBlock",
6305                        "attrs": {"language": ""},
6306                        "content": [{"type": "text", "text": "error: some value with a backtick ` at end"}]
6307                    }]
6308                }]
6309            }]
6310        }"#;
6311        let original: AdfDocument = serde_json::from_str(json).unwrap();
6312        let md = adf_to_markdown(&original).unwrap();
6313        let roundtripped = markdown_to_adf(&md).unwrap();
6314        let list = &roundtripped.content[0];
6315        assert_eq!(list.node_type, "bulletList", "top node: {}", list.node_type);
6316        let item = &list.content.as_ref().unwrap()[0];
6317        let first_child = &item.content.as_ref().unwrap()[0];
6318        assert_eq!(
6319            first_child.node_type, "codeBlock",
6320            "expected codeBlock, got: {}",
6321            first_child.node_type
6322        );
6323        let text = first_child.content.as_ref().unwrap()[0]
6324            .text
6325            .as_deref()
6326            .unwrap();
6327        assert_eq!(text, "error: some value with a backtick ` at end");
6328    }
6329
6330    /// Code block with language tag inside a list item round-trips.
6331    #[test]
6332    fn code_block_with_language_in_list_item_roundtrip() {
6333        let json = r#"{
6334            "version": 1,
6335            "type": "doc",
6336            "content": [{
6337                "type": "bulletList",
6338                "content": [{
6339                    "type": "listItem",
6340                    "content": [{
6341                        "type": "codeBlock",
6342                        "attrs": {"language": "rust"},
6343                        "content": [{"type": "text", "text": "fn main() {\n    println!(\"hello\");\n}"}]
6344                    }]
6345                }]
6346            }]
6347        }"#;
6348        let original: AdfDocument = serde_json::from_str(json).unwrap();
6349        let md = adf_to_markdown(&original).unwrap();
6350        let roundtripped = markdown_to_adf(&md).unwrap();
6351        let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
6352        let code = &item.content.as_ref().unwrap()[0];
6353        assert_eq!(code.node_type, "codeBlock");
6354        let lang = code
6355            .attrs
6356            .as_ref()
6357            .and_then(|a| a.get("language"))
6358            .and_then(serde_json::Value::as_str)
6359            .unwrap_or("");
6360        assert_eq!(lang, "rust");
6361        let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
6362        assert!(text.contains("println!"), "code content: {text}");
6363    }
6364
6365    /// Code block in an ordered list item round-trips correctly.
6366    #[test]
6367    fn code_block_in_ordered_list_item_roundtrip() {
6368        let json = r#"{
6369            "version": 1,
6370            "type": "doc",
6371            "content": [{
6372                "type": "orderedList",
6373                "attrs": {"order": 1},
6374                "content": [{
6375                    "type": "listItem",
6376                    "content": [{
6377                        "type": "codeBlock",
6378                        "attrs": {"language": ""},
6379                        "content": [{"type": "text", "text": "backtick ` here"}]
6380                    }]
6381                }]
6382            }]
6383        }"#;
6384        let original: AdfDocument = serde_json::from_str(json).unwrap();
6385        let md = adf_to_markdown(&original).unwrap();
6386        let roundtripped = markdown_to_adf(&md).unwrap();
6387        let list = &roundtripped.content[0];
6388        assert_eq!(list.node_type, "orderedList");
6389        let item = &list.content.as_ref().unwrap()[0];
6390        let code = &item.content.as_ref().unwrap()[0];
6391        assert_eq!(code.node_type, "codeBlock");
6392        let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
6393        assert_eq!(text, "backtick ` here");
6394    }
6395
6396    /// A list item with a code block followed by a paragraph round-trips.
6397    #[test]
6398    fn code_block_then_paragraph_in_list_item() {
6399        let json = r#"{
6400            "version": 1,
6401            "type": "doc",
6402            "content": [{
6403                "type": "bulletList",
6404                "content": [{
6405                    "type": "listItem",
6406                    "content": [
6407                        {
6408                            "type": "codeBlock",
6409                            "attrs": {"language": ""},
6410                            "content": [{"type": "text", "text": "code with ` backtick"}]
6411                        },
6412                        {
6413                            "type": "paragraph",
6414                            "content": [{"type": "text", "text": "description"}]
6415                        }
6416                    ]
6417                }]
6418            }]
6419        }"#;
6420        let original: AdfDocument = serde_json::from_str(json).unwrap();
6421        let md = adf_to_markdown(&original).unwrap();
6422        let roundtripped = markdown_to_adf(&md).unwrap();
6423        let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
6424        let children = item.content.as_ref().unwrap();
6425        assert_eq!(children[0].node_type, "codeBlock");
6426        assert_eq!(children[1].node_type, "paragraph");
6427    }
6428
6429    /// Multiple backticks in code block content round-trip.
6430    #[test]
6431    fn code_block_multiple_backticks_in_list_item() {
6432        let json = r#"{
6433            "version": 1,
6434            "type": "doc",
6435            "content": [{
6436                "type": "bulletList",
6437                "content": [{
6438                    "type": "listItem",
6439                    "content": [{
6440                        "type": "codeBlock",
6441                        "attrs": {"language": ""},
6442                        "content": [{"type": "text", "text": "a ` b `` c ``` d"}]
6443                    }]
6444                }]
6445            }]
6446        }"#;
6447        let original: AdfDocument = serde_json::from_str(json).unwrap();
6448        let md = adf_to_markdown(&original).unwrap();
6449        let roundtripped = markdown_to_adf(&md).unwrap();
6450        let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
6451        let code = &item.content.as_ref().unwrap()[0];
6452        assert_eq!(code.node_type, "codeBlock");
6453        let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
6454        assert_eq!(text, "a ` b `` c ``` d");
6455    }
6456
6457    /// Media as the first child of a list item with a subsequent paragraph
6458    /// exercises the media + sub_lines branch in `parse_list_item_first_line`.
6459    #[test]
6460    fn media_first_child_with_sub_content_in_list_item() {
6461        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
6462          {"type":"listItem","content":[
6463            {"type":"mediaSingle","attrs":{"layout":"center"},
6464             "content":[{"type":"media","attrs":{"type":"file","id":"img-99","collection":"col-x","height":50,"width":100}}]},
6465            {"type":"paragraph","content":[{"type":"text","text":"Caption below"}]}
6466          ]}
6467        ]}]}"#;
6468        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
6469        let md = adf_to_markdown(&doc).unwrap();
6470        let rt = markdown_to_adf(&md).unwrap();
6471        let item = &rt.content[0].content.as_ref().unwrap()[0];
6472        let children = item.content.as_ref().unwrap();
6473        assert_eq!(
6474            children.len(),
6475            2,
6476            "expected 2 children, got {}",
6477            children.len()
6478        );
6479        assert_eq!(children[0].node_type, "mediaSingle");
6480        let media = &children[0].content.as_ref().unwrap()[0];
6481        assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-99");
6482        assert_eq!(children[1].node_type, "paragraph");
6483    }
6484
6485    #[test]
6486    fn inline_bold() {
6487        let doc = markdown_to_adf("Some **bold** text").unwrap();
6488        let content = doc.content[0].content.as_ref().unwrap();
6489        assert!(content.len() >= 3);
6490        let bold_node = &content[1];
6491        assert_eq!(bold_node.text.as_deref(), Some("bold"));
6492        let marks = bold_node.marks.as_ref().unwrap();
6493        assert_eq!(marks[0].mark_type, "strong");
6494    }
6495
6496    #[test]
6497    fn inline_italic() {
6498        let doc = markdown_to_adf("Some *italic* text").unwrap();
6499        let content = doc.content[0].content.as_ref().unwrap();
6500        let italic_node = &content[1];
6501        assert_eq!(italic_node.text.as_deref(), Some("italic"));
6502        let marks = italic_node.marks.as_ref().unwrap();
6503        assert_eq!(marks[0].mark_type, "em");
6504    }
6505
6506    #[test]
6507    fn inline_code() {
6508        let doc = markdown_to_adf("Use `code` here").unwrap();
6509        let content = doc.content[0].content.as_ref().unwrap();
6510        let code_node = &content[1];
6511        assert_eq!(code_node.text.as_deref(), Some("code"));
6512        let marks = code_node.marks.as_ref().unwrap();
6513        assert_eq!(marks[0].mark_type, "code");
6514    }
6515
6516    /// Issue #578: a code-marked text with an internal backtick must be
6517    /// emitted using double-backtick delimiters so it round-trips as a
6518    /// single node rather than being split on the inner backtick.
6519    #[test]
6520    fn inline_code_with_backtick_emitted_with_double_delimiters() {
6521        let doc = AdfDocument {
6522            version: 1,
6523            doc_type: "doc".to_string(),
6524            content: vec![AdfNode::paragraph(vec![
6525                AdfNode::text("Run "),
6526                AdfNode::text_with_marks(
6527                    "ADD `custom_threshold` TEXT NOT NULL",
6528                    vec![AdfMark::code()],
6529                ),
6530                AdfNode::text(" to update the schema."),
6531            ])],
6532        };
6533        let md = adf_to_markdown(&doc).unwrap();
6534        assert!(
6535            md.contains("``ADD `custom_threshold` TEXT NOT NULL``"),
6536            "expected double-backtick delimiters, got: {md}"
6537        );
6538    }
6539
6540    /// Issue #578: double-backtick delimited code spans parse as a single
6541    /// code-marked text node that preserves the embedded single backticks.
6542    #[test]
6543    fn inline_code_double_backtick_delimiters_parse() {
6544        let doc = markdown_to_adf("Run ``ADD `custom_threshold` TEXT NOT NULL`` now").unwrap();
6545        let content = doc.content[0].content.as_ref().unwrap();
6546        assert_eq!(content.len(), 3, "content: {content:?}");
6547        let code_node = &content[1];
6548        assert_eq!(
6549            code_node.text.as_deref(),
6550            Some("ADD `custom_threshold` TEXT NOT NULL")
6551        );
6552        let marks = code_node.marks.as_ref().unwrap();
6553        assert_eq!(marks[0].mark_type, "code");
6554    }
6555
6556    /// Issue #578: the full reproducer — a code-marked text with inner
6557    /// backticks survives ADF → JFM → ADF round-trip intact.
6558    #[test]
6559    fn inline_code_with_backtick_roundtrip() {
6560        let json = r#"{
6561            "version": 1,
6562            "type": "doc",
6563            "content": [{
6564                "type": "paragraph",
6565                "content": [
6566                    {"type": "text", "text": "Run "},
6567                    {
6568                        "type": "text",
6569                        "text": "ADD `custom_threshold` TEXT NOT NULL",
6570                        "marks": [{"type": "code"}]
6571                    },
6572                    {"type": "text", "text": " to update the schema."}
6573                ]
6574            }]
6575        }"#;
6576        let original: AdfDocument = serde_json::from_str(json).unwrap();
6577        let md = adf_to_markdown(&original).unwrap();
6578        let roundtripped = markdown_to_adf(&md).unwrap();
6579        let para = &roundtripped.content[0];
6580        let children = para.content.as_ref().unwrap();
6581        assert_eq!(children.len(), 3, "expected 3 children, got: {children:?}");
6582        assert_eq!(children[0].text.as_deref(), Some("Run "));
6583        assert_eq!(
6584            children[1].text.as_deref(),
6585            Some("ADD `custom_threshold` TEXT NOT NULL")
6586        );
6587        let marks = children[1].marks.as_ref().unwrap();
6588        assert_eq!(marks.len(), 1);
6589        assert_eq!(marks[0].mark_type, "code");
6590        assert_eq!(children[2].text.as_deref(), Some(" to update the schema."));
6591    }
6592
6593    /// A code-marked text containing a run of two backticks should be
6594    /// emitted with triple-backtick delimiters and round-trip intact —
6595    /// the first line of the paragraph also starts with the fence so this
6596    /// exercises the info-string-with-backtick fence-opener rejection.
6597    #[test]
6598    fn inline_code_with_double_backtick_roundtrip() {
6599        let doc = AdfDocument {
6600            version: 1,
6601            doc_type: "doc".to_string(),
6602            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6603                "x `` y",
6604                vec![AdfMark::code()],
6605            )])],
6606        };
6607        let md = adf_to_markdown(&doc).unwrap();
6608        let roundtripped = markdown_to_adf(&md).unwrap();
6609        let content = roundtripped.content[0].content.as_ref().unwrap();
6610        assert_eq!(content.len(), 1);
6611        assert_eq!(content[0].text.as_deref(), Some("x `` y"));
6612        let marks = content[0].marks.as_ref().unwrap();
6613        assert_eq!(marks[0].mark_type, "code");
6614    }
6615
6616    /// A code-marked text that begins with a backtick must be padded on
6617    /// both sides so the CommonMark space-stripping rule reconstructs it.
6618    #[test]
6619    fn inline_code_leading_backtick_roundtrip() {
6620        let doc = AdfDocument {
6621            version: 1,
6622            doc_type: "doc".to_string(),
6623            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6624                "`start",
6625                vec![AdfMark::code()],
6626            )])],
6627        };
6628        let md = adf_to_markdown(&doc).unwrap();
6629        let roundtripped = markdown_to_adf(&md).unwrap();
6630        let content = roundtripped.content[0].content.as_ref().unwrap();
6631        assert_eq!(content[0].text.as_deref(), Some("`start"));
6632        assert_eq!(content[0].marks.as_ref().unwrap()[0].mark_type, "code");
6633    }
6634
6635    /// A code-marked text that ends with a backtick must also survive.
6636    #[test]
6637    fn inline_code_trailing_backtick_roundtrip() {
6638        let doc = AdfDocument {
6639            version: 1,
6640            doc_type: "doc".to_string(),
6641            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6642                "end`",
6643                vec![AdfMark::code()],
6644            )])],
6645        };
6646        let md = adf_to_markdown(&doc).unwrap();
6647        let roundtripped = markdown_to_adf(&md).unwrap();
6648        let content = roundtripped.content[0].content.as_ref().unwrap();
6649        assert_eq!(content[0].text.as_deref(), Some("end`"));
6650    }
6651
6652    /// Content that both begins and ends with a space (but is not all
6653    /// spaces) needs padding so the stripping rule leaves it intact.
6654    #[test]
6655    fn inline_code_space_padded_content_roundtrip() {
6656        let doc = AdfDocument {
6657            version: 1,
6658            doc_type: "doc".to_string(),
6659            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6660                " foo ",
6661                vec![AdfMark::code()],
6662            )])],
6663        };
6664        let md = adf_to_markdown(&doc).unwrap();
6665        let roundtripped = markdown_to_adf(&md).unwrap();
6666        let content = roundtripped.content[0].content.as_ref().unwrap();
6667        assert_eq!(content[0].text.as_deref(), Some(" foo "));
6668    }
6669
6670    /// All-space content must round-trip without the stripping rule
6671    /// kicking in (per CommonMark: all-space content is not stripped).
6672    #[test]
6673    fn inline_code_all_spaces_roundtrip() {
6674        let doc = AdfDocument {
6675            version: 1,
6676            doc_type: "doc".to_string(),
6677            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6678                "   ",
6679                vec![AdfMark::code()],
6680            )])],
6681        };
6682        let md = adf_to_markdown(&doc).unwrap();
6683        let roundtripped = markdown_to_adf(&md).unwrap();
6684        let content = roundtripped.content[0].content.as_ref().unwrap();
6685        assert_eq!(content[0].text.as_deref(), Some("   "));
6686    }
6687
6688    /// A code+link mark where the code text contains a backtick must also
6689    /// round-trip — verifies the link branch of code-span rendering.
6690    #[test]
6691    fn inline_code_with_link_and_backtick_roundtrip() {
6692        let doc = AdfDocument {
6693            version: 1,
6694            doc_type: "doc".to_string(),
6695            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6696                "fn `inner`",
6697                vec![AdfMark::code(), AdfMark::link("https://example.com")],
6698            )])],
6699        };
6700        let md = adf_to_markdown(&doc).unwrap();
6701        assert!(
6702            md.contains("`` fn `inner` ``"),
6703            "expected padded double-backtick delimiters inside link, got: {md}"
6704        );
6705        let roundtripped = markdown_to_adf(&md).unwrap();
6706        let content = roundtripped.content[0].content.as_ref().unwrap();
6707        assert_eq!(content[0].text.as_deref(), Some("fn `inner`"));
6708        let mark_types: Vec<&str> = content[0]
6709            .marks
6710            .as_ref()
6711            .unwrap()
6712            .iter()
6713            .map(|m| m.mark_type.as_str())
6714            .collect();
6715        assert!(mark_types.contains(&"code"));
6716        assert!(mark_types.contains(&"link"));
6717    }
6718
6719    /// Unmatched opening backticks must not be parsed as a code span.
6720    #[test]
6721    fn inline_code_unmatched_run_is_plain_text() {
6722        let doc = markdown_to_adf("foo ``bar baz").unwrap();
6723        let content = doc.content[0].content.as_ref().unwrap();
6724        assert_eq!(content.len(), 1);
6725        assert_eq!(content[0].text.as_deref(), Some("foo ``bar baz"));
6726        assert!(content[0].marks.is_none());
6727    }
6728
6729    /// Mismatched delimiter lengths must not form a code span.  Per
6730    /// CommonMark the opening 2-backtick run and the trailing 1-backtick
6731    /// run never form a valid code span and the characters stay literal.
6732    #[test]
6733    fn inline_code_mismatched_delimiters_is_plain_text() {
6734        let doc = markdown_to_adf("``foo` bar").unwrap();
6735        let content = doc.content[0].content.as_ref().unwrap();
6736        assert_eq!(content.len(), 1);
6737        assert_eq!(content[0].text.as_deref(), Some("``foo` bar"));
6738        assert!(content[0].marks.is_none());
6739    }
6740
6741    #[test]
6742    fn inline_code_delimiter_chooses_correct_length() {
6743        assert_eq!(inline_code_delimiter("no ticks"), (1, false));
6744        assert_eq!(inline_code_delimiter("one ` here"), (2, false));
6745        assert_eq!(inline_code_delimiter("two `` here"), (3, false));
6746        assert_eq!(inline_code_delimiter("three ``` here"), (4, false));
6747        assert_eq!(inline_code_delimiter("`leading"), (2, true));
6748        assert_eq!(inline_code_delimiter("trailing`"), (2, true));
6749        assert_eq!(inline_code_delimiter(" foo "), (1, true));
6750        assert_eq!(inline_code_delimiter(" "), (1, false));
6751        assert_eq!(inline_code_delimiter("   "), (1, false));
6752        assert_eq!(inline_code_delimiter(" foo"), (1, false));
6753    }
6754
6755    #[test]
6756    fn try_parse_inline_code_strips_paired_spaces() {
6757        let (end, content) = try_parse_inline_code("`` `foo` ``", 0).unwrap();
6758        assert_eq!(end, 11);
6759        assert_eq!(content, "`foo`");
6760    }
6761
6762    #[test]
6763    fn try_parse_inline_code_all_space_content_is_preserved() {
6764        let (_end, content) = try_parse_inline_code("`   `", 0).unwrap();
6765        assert_eq!(content, "   ");
6766    }
6767
6768    #[test]
6769    fn try_parse_inline_code_single_run_matches_first_close() {
6770        let (end, content) = try_parse_inline_code("`foo` tail", 0).unwrap();
6771        assert_eq!(end, 5);
6772        assert_eq!(content, "foo");
6773    }
6774
6775    #[test]
6776    fn try_parse_inline_code_no_match_returns_none() {
6777        assert!(try_parse_inline_code("``unmatched", 0).is_none());
6778        assert!(try_parse_inline_code("plain text", 0).is_none());
6779    }
6780
6781    #[test]
6782    fn is_code_fence_opener_rejects_info_with_backtick() {
6783        assert!(is_code_fence_opener("```"));
6784        assert!(is_code_fence_opener("```rust"));
6785        assert!(is_code_fence_opener("```\"\""));
6786        assert!(!is_code_fence_opener("```x `` y```"));
6787        assert!(!is_code_fence_opener("``not-enough"));
6788        assert!(!is_code_fence_opener("no fence"));
6789    }
6790
6791    #[test]
6792    fn inline_strikethrough() {
6793        let doc = markdown_to_adf("Some ~~deleted~~ text").unwrap();
6794        let content = doc.content[0].content.as_ref().unwrap();
6795        let strike_node = &content[1];
6796        assert_eq!(strike_node.text.as_deref(), Some("deleted"));
6797        let marks = strike_node.marks.as_ref().unwrap();
6798        assert_eq!(marks[0].mark_type, "strike");
6799    }
6800
6801    #[test]
6802    fn inline_link() {
6803        let doc = markdown_to_adf("Click [here](https://example.com) now").unwrap();
6804        let content = doc.content[0].content.as_ref().unwrap();
6805        let link_node = &content[1];
6806        assert_eq!(link_node.text.as_deref(), Some("here"));
6807        let marks = link_node.marks.as_ref().unwrap();
6808        assert_eq!(marks[0].mark_type, "link");
6809    }
6810
6811    #[test]
6812    fn block_image() {
6813        let md = "![Alt text](https://example.com/image.png)";
6814        let doc = markdown_to_adf(md).unwrap();
6815        assert_eq!(doc.content[0].node_type, "mediaSingle");
6816    }
6817
6818    #[test]
6819    fn table() {
6820        let md = "| A | B |\n| --- | --- |\n| 1 | 2 |";
6821        let doc = markdown_to_adf(md).unwrap();
6822        assert_eq!(doc.content[0].node_type, "table");
6823        let rows = doc.content[0].content.as_ref().unwrap();
6824        assert_eq!(rows.len(), 2); // header + 1 body row
6825    }
6826
6827    // ── adf_to_markdown tests ───────────────────────────────────────
6828
6829    #[test]
6830    fn adf_paragraph_to_markdown() {
6831        let doc = AdfDocument {
6832            version: 1,
6833            doc_type: "doc".to_string(),
6834            content: vec![AdfNode::paragraph(vec![AdfNode::text("Hello world")])],
6835        };
6836        let md = adf_to_markdown(&doc).unwrap();
6837        assert_eq!(md.trim(), "Hello world");
6838    }
6839
6840    #[test]
6841    fn adf_heading_to_markdown() {
6842        let doc = AdfDocument {
6843            version: 1,
6844            doc_type: "doc".to_string(),
6845            content: vec![AdfNode::heading(2, vec![AdfNode::text("Title")])],
6846        };
6847        let md = adf_to_markdown(&doc).unwrap();
6848        assert_eq!(md.trim(), "## Title");
6849    }
6850
6851    #[test]
6852    fn adf_bold_to_markdown() {
6853        let doc = AdfDocument {
6854            version: 1,
6855            doc_type: "doc".to_string(),
6856            content: vec![AdfNode::paragraph(vec![
6857                AdfNode::text("Normal "),
6858                AdfNode::text_with_marks("bold", vec![AdfMark::strong()]),
6859                AdfNode::text(" text"),
6860            ])],
6861        };
6862        let md = adf_to_markdown(&doc).unwrap();
6863        assert_eq!(md.trim(), "Normal **bold** text");
6864    }
6865
6866    #[test]
6867    fn adf_code_block_to_markdown() {
6868        let doc = AdfDocument {
6869            version: 1,
6870            doc_type: "doc".to_string(),
6871            content: vec![AdfNode::code_block(Some("rust"), "let x = 1;")],
6872        };
6873        let md = adf_to_markdown(&doc).unwrap();
6874        assert!(md.contains("```rust"));
6875        assert!(md.contains("let x = 1;"));
6876        assert!(md.contains("```"));
6877    }
6878
6879    #[test]
6880    fn adf_rule_to_markdown() {
6881        let doc = AdfDocument {
6882            version: 1,
6883            doc_type: "doc".to_string(),
6884            content: vec![AdfNode::rule()],
6885        };
6886        let md = adf_to_markdown(&doc).unwrap();
6887        assert!(md.contains("---"));
6888    }
6889
6890    #[test]
6891    fn adf_bullet_list_to_markdown() {
6892        let doc = AdfDocument {
6893            version: 1,
6894            doc_type: "doc".to_string(),
6895            content: vec![AdfNode::bullet_list(vec![
6896                AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("A")])]),
6897                AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("B")])]),
6898            ])],
6899        };
6900        let md = adf_to_markdown(&doc).unwrap();
6901        assert!(md.contains("- A"));
6902        assert!(md.contains("- B"));
6903    }
6904
6905    #[test]
6906    fn adf_link_to_markdown() {
6907        let doc = AdfDocument {
6908            version: 1,
6909            doc_type: "doc".to_string(),
6910            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6911                "click",
6912                vec![AdfMark::link("https://example.com")],
6913            )])],
6914        };
6915        let md = adf_to_markdown(&doc).unwrap();
6916        assert_eq!(md.trim(), "[click](https://example.com)");
6917    }
6918
6919    #[test]
6920    fn unsupported_block_preserved_as_json() {
6921        let doc = AdfDocument {
6922            version: 1,
6923            doc_type: "doc".to_string(),
6924            content: vec![AdfNode {
6925                node_type: "unknownBlock".to_string(),
6926                attrs: Some(serde_json::json!({"key": "value"})),
6927                content: None,
6928                text: None,
6929                marks: None,
6930                local_id: None,
6931                parameters: None,
6932            }],
6933        };
6934        let md = adf_to_markdown(&doc).unwrap();
6935        assert!(md.contains("```adf-unsupported"));
6936        assert!(md.contains("\"unknownBlock\""));
6937    }
6938
6939    #[test]
6940    fn unsupported_block_round_trips() {
6941        let original = AdfDocument {
6942            version: 1,
6943            doc_type: "doc".to_string(),
6944            content: vec![AdfNode {
6945                node_type: "unknownBlock".to_string(),
6946                attrs: Some(serde_json::json!({"key": "value"})),
6947                content: None,
6948                text: None,
6949                marks: None,
6950                local_id: None,
6951                parameters: None,
6952            }],
6953        };
6954        let md = adf_to_markdown(&original).unwrap();
6955        let restored = markdown_to_adf(&md).unwrap();
6956        assert_eq!(restored.content[0].node_type, "unknownBlock");
6957        assert_eq!(restored.content[0].attrs.as_ref().unwrap()["key"], "value");
6958    }
6959
6960    // ── Round-trip tests ────────────────────────────────────────────
6961
6962    #[test]
6963    fn round_trip_simple_document() {
6964        let md = "# Hello\n\nSome text with **bold** and *italic*.\n\n- Item 1\n- Item 2\n";
6965        let adf = markdown_to_adf(md).unwrap();
6966        let restored = adf_to_markdown(&adf).unwrap();
6967
6968        assert!(restored.contains("# Hello"));
6969        assert!(restored.contains("**bold**"));
6970        assert!(restored.contains("*italic*"));
6971        assert!(restored.contains("- Item 1"));
6972        assert!(restored.contains("- Item 2"));
6973    }
6974
6975    #[test]
6976    fn round_trip_code_block() {
6977        let md = "```python\nprint('hello')\n```\n";
6978        let adf = markdown_to_adf(md).unwrap();
6979        let restored = adf_to_markdown(&adf).unwrap();
6980
6981        assert!(restored.contains("```python"));
6982        assert!(restored.contains("print('hello')"));
6983    }
6984
6985    #[test]
6986    fn round_trip_code_block_no_attrs() {
6987        let adf_json = r#"{"version":1,"type":"doc","content":[
6988            {"type":"codeBlock","content":[{"type":"text","text":"plain code"}]}
6989        ]}"#;
6990        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
6991        assert!(doc.content[0].attrs.is_none());
6992        let md = adf_to_markdown(&doc).unwrap();
6993        let round_tripped = markdown_to_adf(&md).unwrap();
6994        assert!(round_tripped.content[0].attrs.is_none());
6995    }
6996
6997    #[test]
6998    fn round_trip_code_block_empty_language() {
6999        let adf_json = r#"{"version":1,"type":"doc","content":[
7000            {"type":"codeBlock","attrs":{"language":""},"content":[{"type":"text","text":"simple code block no backtick"}]}
7001        ]}"#;
7002        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
7003        let attrs = doc.content[0].attrs.as_ref().unwrap();
7004        assert_eq!(attrs["language"], "");
7005        let md = adf_to_markdown(&doc).unwrap();
7006        let round_tripped = markdown_to_adf(&md).unwrap();
7007        let rt_attrs = round_tripped.content[0].attrs.as_ref().unwrap();
7008        assert_eq!(rt_attrs["language"], "");
7009    }
7010
7011    #[test]
7012    fn round_trip_code_block_with_language() {
7013        let adf_json = r#"{"version":1,"type":"doc","content":[
7014            {"type":"codeBlock","attrs":{"language":"python"},"content":[{"type":"text","text":"print('hi')"}]}
7015        ]}"#;
7016        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
7017        let md = adf_to_markdown(&doc).unwrap();
7018        let round_tripped = markdown_to_adf(&md).unwrap();
7019        let rt_attrs = round_tripped.content[0].attrs.as_ref().unwrap();
7020        assert_eq!(rt_attrs["language"], "python");
7021    }
7022
7023    #[test]
7024    fn multiple_paragraphs() {
7025        let md = "First paragraph.\n\nSecond paragraph.\n";
7026        let adf = markdown_to_adf(md).unwrap();
7027        assert_eq!(adf.content.len(), 2);
7028        assert_eq!(adf.content[0].node_type, "paragraph");
7029        assert_eq!(adf.content[1].node_type, "paragraph");
7030    }
7031
7032    // ── Additional markdown_to_adf tests ───────────────────────────────
7033
7034    #[test]
7035    fn horizontal_rule_underscores() {
7036        let doc = markdown_to_adf("___").unwrap();
7037        assert_eq!(doc.content[0].node_type, "rule");
7038    }
7039
7040    #[test]
7041    fn not_a_horizontal_rule_too_short() {
7042        let doc = markdown_to_adf("--").unwrap();
7043        assert_eq!(doc.content[0].node_type, "paragraph");
7044    }
7045
7046    #[test]
7047    fn bullet_list_star_marker() {
7048        let md = "* Apple\n* Banana";
7049        let doc = markdown_to_adf(md).unwrap();
7050        assert_eq!(doc.content[0].node_type, "bulletList");
7051        let items = doc.content[0].content.as_ref().unwrap();
7052        assert_eq!(items.len(), 2);
7053    }
7054
7055    #[test]
7056    fn bullet_list_plus_marker() {
7057        let md = "+ One\n+ Two";
7058        let doc = markdown_to_adf(md).unwrap();
7059        assert_eq!(doc.content[0].node_type, "bulletList");
7060    }
7061
7062    #[test]
7063    fn ordered_list_non_one_start() {
7064        let md = "5. Fifth\n6. Sixth";
7065        let doc = markdown_to_adf(md).unwrap();
7066        let node = &doc.content[0];
7067        assert_eq!(node.node_type, "orderedList");
7068        let attrs = node.attrs.as_ref().unwrap();
7069        assert_eq!(attrs["order"], 5);
7070    }
7071
7072    #[test]
7073    fn ordered_list_start_at_one_omits_order_attr() {
7074        // Issue #547: order=1 is the default and must be omitted from attrs
7075        // so that ADF→JFM→ADF round-trip is byte-identical for the common
7076        // case where the source ADF has no attrs object on orderedList.
7077        let md = "1. First\n2. Second";
7078        let doc = markdown_to_adf(md).unwrap();
7079        let node = &doc.content[0];
7080        assert_eq!(node.node_type, "orderedList");
7081        assert!(
7082            node.attrs.is_none(),
7083            "attrs should be omitted when order=1, got: {:?}",
7084            node.attrs
7085        );
7086    }
7087
7088    #[test]
7089    fn blockquote_bare_marker() {
7090        // ">" with no space after
7091        let md = ">quoted text";
7092        let doc = markdown_to_adf(md).unwrap();
7093        assert_eq!(doc.content[0].node_type, "blockquote");
7094    }
7095
7096    #[test]
7097    fn image_no_alt() {
7098        let md = "![](https://example.com/img.png)";
7099        let doc = markdown_to_adf(md).unwrap();
7100        let node = &doc.content[0];
7101        assert_eq!(node.node_type, "mediaSingle");
7102        // media child should have no alt attr
7103        let media = &node.content.as_ref().unwrap()[0];
7104        let attrs = media.attrs.as_ref().unwrap();
7105        assert!(attrs.get("alt").is_none());
7106    }
7107
7108    #[test]
7109    fn image_with_alt() {
7110        let md = "![A photo](https://example.com/photo.jpg)";
7111        let doc = markdown_to_adf(md).unwrap();
7112        let media = &doc.content[0].content.as_ref().unwrap()[0];
7113        let attrs = media.attrs.as_ref().unwrap();
7114        assert_eq!(attrs["alt"], "A photo");
7115    }
7116
7117    #[test]
7118    fn table_multi_body_rows() {
7119        let md = "| H1 | H2 |\n| --- | --- |\n| a | b |\n| c | d |";
7120        let doc = markdown_to_adf(md).unwrap();
7121        let rows = doc.content[0].content.as_ref().unwrap();
7122        assert_eq!(rows.len(), 3); // header + 2 body rows
7123                                   // First row cells are tableHeader
7124        let header_cells = rows[0].content.as_ref().unwrap();
7125        assert_eq!(header_cells[0].node_type, "tableHeader");
7126        // Body row cells are tableCell
7127        let body_cells = rows[1].content.as_ref().unwrap();
7128        assert_eq!(body_cells[0].node_type, "tableCell");
7129    }
7130
7131    #[test]
7132    fn table_no_separator_is_not_table() {
7133        // Pipe characters without a separator row should not parse as table
7134        let md = "| not | a table |";
7135        let doc = markdown_to_adf(md).unwrap();
7136        assert_eq!(doc.content[0].node_type, "paragraph");
7137    }
7138
7139    #[test]
7140    fn inline_underscore_bold() {
7141        let doc = markdown_to_adf("Some __bold__ text").unwrap();
7142        let content = doc.content[0].content.as_ref().unwrap();
7143        let bold_node = &content[1];
7144        assert_eq!(bold_node.text.as_deref(), Some("bold"));
7145        let marks = bold_node.marks.as_ref().unwrap();
7146        assert_eq!(marks[0].mark_type, "strong");
7147    }
7148
7149    #[test]
7150    fn inline_underscore_italic() {
7151        let doc = markdown_to_adf("Some _italic_ text").unwrap();
7152        let content = doc.content[0].content.as_ref().unwrap();
7153        let italic_node = &content[1];
7154        assert_eq!(italic_node.text.as_deref(), Some("italic"));
7155        let marks = italic_node.marks.as_ref().unwrap();
7156        assert_eq!(marks[0].mark_type, "em");
7157    }
7158
7159    #[test]
7160    fn intraword_underscore_not_emphasis() {
7161        // Single intraword underscore pair: do_something_useful
7162        let doc = markdown_to_adf("call do_something_useful now").unwrap();
7163        let content = doc.content[0].content.as_ref().unwrap();
7164        assert_eq!(content.len(), 1, "should be a single text node");
7165        assert_eq!(
7166            content[0].text.as_deref(),
7167            Some("call do_something_useful now")
7168        );
7169        assert!(content[0].marks.is_none());
7170    }
7171
7172    #[test]
7173    fn intraword_underscore_multiple() {
7174        // Multiple intraword underscores: a_b_c_d
7175        let doc = markdown_to_adf("use a_b_c_d here").unwrap();
7176        let content = doc.content[0].content.as_ref().unwrap();
7177        assert_eq!(content.len(), 1);
7178        assert_eq!(content[0].text.as_deref(), Some("use a_b_c_d here"));
7179        assert!(content[0].marks.is_none());
7180    }
7181
7182    #[test]
7183    fn intraword_double_underscore_not_bold() {
7184        // Intraword double underscores: foo__bar__baz
7185        let doc = markdown_to_adf("foo__bar__baz").unwrap();
7186        let content = doc.content[0].content.as_ref().unwrap();
7187        assert_eq!(content.len(), 1);
7188        assert_eq!(content[0].text.as_deref(), Some("foo__bar__baz"));
7189        assert!(content[0].marks.is_none());
7190    }
7191
7192    #[test]
7193    fn intraword_triple_underscore_not_bold_italic() {
7194        // Intraword triple underscores: x___y___z
7195        let doc = markdown_to_adf("x___y___z").unwrap();
7196        let content = doc.content[0].content.as_ref().unwrap();
7197        assert_eq!(content.len(), 1);
7198        assert_eq!(content[0].text.as_deref(), Some("x___y___z"));
7199        assert!(content[0].marks.is_none());
7200    }
7201
7202    #[test]
7203    fn underscore_emphasis_still_works_with_spaces() {
7204        // Normal emphasis with spaces around delimiters should still work
7205        let doc = markdown_to_adf("some _italic_ here").unwrap();
7206        let content = doc.content[0].content.as_ref().unwrap();
7207        assert_eq!(content.len(), 3);
7208        assert_eq!(content[1].text.as_deref(), Some("italic"));
7209        let marks = content[1].marks.as_ref().unwrap();
7210        assert_eq!(marks[0].mark_type, "em");
7211    }
7212
7213    #[test]
7214    fn underscore_bold_still_works_with_spaces() {
7215        // Normal bold with spaces around delimiters should still work
7216        let doc = markdown_to_adf("some __bold__ here").unwrap();
7217        let content = doc.content[0].content.as_ref().unwrap();
7218        assert_eq!(content.len(), 3);
7219        assert_eq!(content[1].text.as_deref(), Some("bold"));
7220        let marks = content[1].marks.as_ref().unwrap();
7221        assert_eq!(marks[0].mark_type, "strong");
7222    }
7223
7224    #[test]
7225    fn intraword_underscore_closing_only() {
7226        // Opening _ is valid (preceded by space) but closing _ is intraword: _foo_bar
7227        let doc = markdown_to_adf("_foo_bar").unwrap();
7228        let content = doc.content[0].content.as_ref().unwrap();
7229        assert_eq!(content.len(), 1);
7230        assert_eq!(content[0].text.as_deref(), Some("_foo_bar"));
7231    }
7232
7233    #[test]
7234    fn intraword_double_underscore_closing_only() {
7235        // Opening __ is valid (at start) but closing __ is intraword: __foo__bar
7236        let doc = markdown_to_adf("__foo__bar").unwrap();
7237        let content = doc.content[0].content.as_ref().unwrap();
7238        assert_eq!(content.len(), 1);
7239        assert_eq!(content[0].text.as_deref(), Some("__foo__bar"));
7240    }
7241
7242    #[test]
7243    fn intraword_triple_underscore_closing_only() {
7244        // Opening ___ is valid (at start) but closing ___ is intraword: ___foo___bar
7245        let doc = markdown_to_adf("___foo___bar").unwrap();
7246        let content = doc.content[0].content.as_ref().unwrap();
7247        assert_eq!(content.len(), 1);
7248        assert_eq!(content[0].text.as_deref(), Some("___foo___bar"));
7249    }
7250
7251    #[test]
7252    fn asterisk_emphasis_unaffected_by_intraword_fix() {
7253        // Asterisks should still work for intraword emphasis (CommonMark allows this)
7254        let doc = markdown_to_adf("foo*bar*baz").unwrap();
7255        let content = doc.content[0].content.as_ref().unwrap();
7256        // Asterisks CAN be intraword emphasis per CommonMark
7257        assert!(content.len() > 1 || content[0].marks.is_some());
7258    }
7259
7260    #[test]
7261    fn intraword_underscore_at_start_of_text() {
7262        // Underscore at start of text is not intraword (no preceding alphanumeric)
7263        let doc = markdown_to_adf("_italic_ word").unwrap();
7264        let content = doc.content[0].content.as_ref().unwrap();
7265        assert_eq!(content[0].text.as_deref(), Some("italic"));
7266        let marks = content[0].marks.as_ref().unwrap();
7267        assert_eq!(marks[0].mark_type, "em");
7268    }
7269
7270    #[test]
7271    fn intraword_underscore_at_end_of_text() {
7272        // Underscore at end of text is not intraword (no following alphanumeric)
7273        let doc = markdown_to_adf("word _italic_").unwrap();
7274        let content = doc.content[0].content.as_ref().unwrap();
7275        let last = content.last().unwrap();
7276        assert_eq!(last.text.as_deref(), Some("italic"));
7277        let marks = last.marks.as_ref().unwrap();
7278        assert_eq!(marks[0].mark_type, "em");
7279    }
7280
7281    #[test]
7282    fn intraword_underscore_opening_only() {
7283        // Opening underscore is intraword but closing is not: a_b c_d
7284        // The first _ is intraword (a_b), so it shouldn't open emphasis
7285        let doc = markdown_to_adf("a_b c_d").unwrap();
7286        let content = doc.content[0].content.as_ref().unwrap();
7287        assert_eq!(content.len(), 1);
7288        assert_eq!(content[0].text.as_deref(), Some("a_b c_d"));
7289    }
7290
7291    #[test]
7292    fn intraword_underscore_roundtrip() {
7293        // The original reproducer from issue #438
7294        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"call the do_something_useful function"}]}]}"#;
7295        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7296        let jfm = adf_to_markdown(&adf).unwrap();
7297        let roundtripped = markdown_to_adf(&jfm).unwrap();
7298        let content = roundtripped.content[0].content.as_ref().unwrap();
7299        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7300        assert_eq!(
7301            content[0].text.as_deref(),
7302            Some("call the do_something_useful function")
7303        );
7304        assert!(content[0].marks.is_none());
7305    }
7306
7307    #[test]
7308    fn asterisk_emphasis_roundtrip() {
7309        // The original reproducer from issue #452
7310        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Status: *confirmed* and active"}]}]}"#;
7311        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7312        let jfm = adf_to_markdown(&adf).unwrap();
7313        let roundtripped = markdown_to_adf(&jfm).unwrap();
7314        let content = roundtripped.content[0].content.as_ref().unwrap();
7315        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7316        assert_eq!(
7317            content[0].text.as_deref(),
7318            Some("Status: *confirmed* and active")
7319        );
7320        assert!(content[0].marks.is_none());
7321    }
7322
7323    #[test]
7324    fn double_asterisk_roundtrip() {
7325        // **bold** delimiters in plain text should not become strong marks
7326        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use **kwargs in Python"}]}]}"#;
7327        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7328        let jfm = adf_to_markdown(&adf).unwrap();
7329        let roundtripped = markdown_to_adf(&jfm).unwrap();
7330        let content = roundtripped.content[0].content.as_ref().unwrap();
7331        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7332        assert_eq!(content[0].text.as_deref(), Some("Use **kwargs in Python"));
7333        assert!(content[0].marks.is_none());
7334    }
7335
7336    #[test]
7337    fn asterisk_with_em_mark_roundtrip() {
7338        // Text that already has an em mark should preserve both the mark and escaped content
7339        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a*b","marks":[{"type":"em"}]}]}]}"#;
7340        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7341        let jfm = adf_to_markdown(&adf).unwrap();
7342        let roundtripped = markdown_to_adf(&jfm).unwrap();
7343        let content = roundtripped.content[0].content.as_ref().unwrap();
7344        // Find the node with em mark
7345        let em_node = content.iter().find(|n| {
7346            n.marks
7347                .as_ref()
7348                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "em"))
7349        });
7350        assert!(em_node.is_some(), "should have an em-marked node");
7351        assert_eq!(em_node.unwrap().text.as_deref(), Some("a*b"));
7352    }
7353
7354    #[test]
7355    fn lone_asterisk_roundtrip() {
7356        // A single asterisk that cannot form emphasis should round-trip
7357        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"rating: 5 * stars"}]}]}"#;
7358        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7359        let jfm = adf_to_markdown(&adf).unwrap();
7360        let roundtripped = markdown_to_adf(&jfm).unwrap();
7361        let content = roundtripped.content[0].content.as_ref().unwrap();
7362        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7363        assert_eq!(content[0].text.as_deref(), Some("rating: 5 * stars"));
7364    }
7365
7366    #[test]
7367    fn escape_emphasis_markers_unit() {
7368        assert_eq!(escape_emphasis_markers("hello"), "hello");
7369        assert_eq!(escape_emphasis_markers("*bold*"), r"\*bold\*");
7370        assert_eq!(escape_emphasis_markers("**strong**"), r"\*\*strong\*\*");
7371        assert_eq!(escape_emphasis_markers("no stars"), "no stars");
7372        assert_eq!(escape_emphasis_markers("a * b"), r"a \* b");
7373        assert_eq!(escape_emphasis_markers(""), "");
7374    }
7375
7376    #[test]
7377    fn escape_emphasis_markers_underscore_intraword() {
7378        // Intraword underscores (alnum on both sides within the node) are
7379        // left as-is — the JFM parser will reject them as emphasis.
7380        assert_eq!(escape_emphasis_markers("foo_bar"), "foo_bar");
7381        assert_eq!(escape_emphasis_markers("a_b_c"), "a_b_c");
7382        assert_eq!(escape_emphasis_markers("foo__bar"), "foo__bar");
7383        assert_eq!(
7384            escape_emphasis_markers("call do_something_useful"),
7385            "call do_something_useful"
7386        );
7387    }
7388
7389    #[test]
7390    fn escape_emphasis_markers_underscore_at_boundary() {
7391        // Leading and trailing underscores get escaped — adjacent text nodes
7392        // could supply the alphanumeric needed to close emphasis (issue #554).
7393        assert_eq!(escape_emphasis_markers("_Action"), r"\_Action");
7394        assert_eq!(escape_emphasis_markers("Action_"), r"Action\_");
7395        assert_eq!(escape_emphasis_markers("_ "), r"\_ ");
7396        assert_eq!(escape_emphasis_markers(" _"), r" \_");
7397        assert_eq!(escape_emphasis_markers("_"), r"\_");
7398    }
7399
7400    #[test]
7401    fn escape_emphasis_markers_underscore_with_punctuation() {
7402        // Underscores adjacent to punctuation (not alphanumeric) get escaped.
7403        assert_eq!(escape_emphasis_markers("foo _bar"), r"foo \_bar");
7404        assert_eq!(escape_emphasis_markers("foo_ bar"), r"foo\_ bar");
7405        assert_eq!(escape_emphasis_markers("(_x_)"), r"(\_x\_)");
7406    }
7407
7408    #[test]
7409    fn find_unescaped_skips_backslash_escaped() {
7410        // Escaped `**` should not be found
7411        assert_eq!(find_unescaped(r"a\*\*b**", "**"), Some(6));
7412        // No unescaped match at all
7413        assert_eq!(find_unescaped(r"a\*\*b", "**"), None);
7414        // Plain match without any escaping
7415        assert_eq!(find_unescaped("a**b", "**"), Some(1));
7416        // Empty haystack
7417        assert_eq!(find_unescaped("", "**"), None);
7418    }
7419
7420    #[test]
7421    fn find_unescaped_char_skips_backslash_escaped() {
7422        // Escaped `*` should not be found
7423        assert_eq!(find_unescaped_char(r"a\*b*", b'*'), Some(4));
7424        // No unescaped match at all
7425        assert_eq!(find_unescaped_char(r"\*", b'*'), None);
7426        // Plain match
7427        assert_eq!(find_unescaped_char("a*b", b'*'), Some(1));
7428        // Empty haystack
7429        assert_eq!(find_unescaped_char("", b'*'), None);
7430    }
7431
7432    #[test]
7433    fn double_asterisk_in_strong_mark_roundtrip() {
7434        // Text with ** inside a strong mark should preserve the literal **
7435        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"call **kwargs","marks":[{"type":"strong"}]}]}]}"#;
7436        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7437        let jfm = adf_to_markdown(&adf).unwrap();
7438        let roundtripped = markdown_to_adf(&jfm).unwrap();
7439        let content = roundtripped.content[0].content.as_ref().unwrap();
7440        let strong_node = content.iter().find(|n| {
7441            n.marks
7442                .as_ref()
7443                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
7444        });
7445        assert!(strong_node.is_some(), "should have a strong-marked node");
7446        assert_eq!(strong_node.unwrap().text.as_deref(), Some("call **kwargs"));
7447    }
7448
7449    #[test]
7450    fn backtick_code_roundtrip() {
7451        // The original reproducer from issue #453
7452        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Set `max_retries` to 3 in the config"}]}]}"#;
7453        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7454        let jfm = adf_to_markdown(&adf).unwrap();
7455        let roundtripped = markdown_to_adf(&jfm).unwrap();
7456        let content = roundtripped.content[0].content.as_ref().unwrap();
7457        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7458        assert_eq!(
7459            content[0].text.as_deref(),
7460            Some("Set `max_retries` to 3 in the config")
7461        );
7462        assert!(content[0].marks.is_none());
7463    }
7464
7465    #[test]
7466    fn multiple_backtick_spans_roundtrip() {
7467        // Multiple backtick-delimited spans in a single text node
7468        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use `foo` and `bar` together"}]}]}"#;
7469        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7470        let jfm = adf_to_markdown(&adf).unwrap();
7471        let roundtripped = markdown_to_adf(&jfm).unwrap();
7472        let content = roundtripped.content[0].content.as_ref().unwrap();
7473        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7474        assert_eq!(
7475            content[0].text.as_deref(),
7476            Some("Use `foo` and `bar` together")
7477        );
7478        assert!(content[0].marks.is_none());
7479    }
7480
7481    #[test]
7482    fn lone_backtick_roundtrip() {
7483        // A single backtick that cannot form a code span should round-trip
7484        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use a ` character"}]}]}"#;
7485        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7486        let jfm = adf_to_markdown(&adf).unwrap();
7487        let roundtripped = markdown_to_adf(&jfm).unwrap();
7488        let content = roundtripped.content[0].content.as_ref().unwrap();
7489        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7490        assert_eq!(content[0].text.as_deref(), Some("Use a ` character"));
7491        assert!(content[0].marks.is_none());
7492    }
7493
7494    #[test]
7495    fn backtick_with_code_mark_roundtrip() {
7496        // Text that already has a code mark should preserve both the mark and content
7497        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"max_retries","marks":[{"type":"code"}]}]}]}"#;
7498        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7499        let jfm = adf_to_markdown(&adf).unwrap();
7500        assert_eq!(jfm.trim(), "`max_retries`");
7501        let roundtripped = markdown_to_adf(&jfm).unwrap();
7502        let content = roundtripped.content[0].content.as_ref().unwrap();
7503        let code_node = content.iter().find(|n| {
7504            n.marks
7505                .as_ref()
7506                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code"))
7507        });
7508        assert!(code_node.is_some(), "should have a code-marked node");
7509        assert_eq!(code_node.unwrap().text.as_deref(), Some("max_retries"));
7510    }
7511
7512    #[test]
7513    fn backtick_with_em_mark_roundtrip() {
7514        // Backtick inside em-marked text should preserve both
7515        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"use `cfg`","marks":[{"type":"em"}]}]}]}"#;
7516        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7517        let jfm = adf_to_markdown(&adf).unwrap();
7518        let roundtripped = markdown_to_adf(&jfm).unwrap();
7519        let content = roundtripped.content[0].content.as_ref().unwrap();
7520        let em_node = content.iter().find(|n| {
7521            n.marks
7522                .as_ref()
7523                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "em"))
7524        });
7525        assert!(em_node.is_some(), "should have an em-marked node");
7526        assert_eq!(em_node.unwrap().text.as_deref(), Some("use `cfg`"));
7527    }
7528
7529    #[test]
7530    fn escape_pipes_in_cell_unit() {
7531        assert_eq!(escape_pipes_in_cell("hello"), "hello");
7532        assert_eq!(escape_pipes_in_cell("a|b"), r"a\|b");
7533        assert_eq!(escape_pipes_in_cell("|"), r"\|");
7534        assert_eq!(escape_pipes_in_cell("|a|b|"), r"\|a\|b\|");
7535        assert_eq!(escape_pipes_in_cell(""), "");
7536        assert_eq!(
7537            escape_pipes_in_cell("`parser.decode[T|json]`"),
7538            r"`parser.decode[T\|json]`"
7539        );
7540    }
7541
7542    #[test]
7543    fn escape_backticks_unit() {
7544        assert_eq!(escape_backticks("hello"), "hello");
7545        assert_eq!(escape_backticks("`code`"), r"\`code\`");
7546        assert_eq!(escape_backticks("no ticks"), "no ticks");
7547        assert_eq!(escape_backticks("a ` b"), r"a \` b");
7548        assert_eq!(escape_backticks(""), "");
7549        assert_eq!(escape_backticks("`a` and `b`"), r"\`a\` and \`b\`");
7550    }
7551
7552    // ── Issue #477: backslash escaping ──────────────────────────────
7553
7554    #[test]
7555    fn backslash_in_text_roundtrip() {
7556        // The original reproducer from issue #477
7557        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"The path is C:\\Users\\admin\\file.txt"}]}]}"#;
7558        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7559        let jfm = adf_to_markdown(&adf).unwrap();
7560        let roundtripped = markdown_to_adf(&jfm).unwrap();
7561        let content = roundtripped.content[0].content.as_ref().unwrap();
7562        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7563        assert_eq!(
7564            content[0].text.as_deref(),
7565            Some(r"The path is C:\Users\admin\file.txt")
7566        );
7567    }
7568
7569    #[test]
7570    fn backslash_emitted_as_double_backslash() {
7571        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a\\b"}]}]}"#;
7572        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7573        let jfm = adf_to_markdown(&adf).unwrap();
7574        assert!(
7575            jfm.contains(r"a\\b"),
7576            "JFM should contain escaped backslash: {jfm}"
7577        );
7578    }
7579
7580    #[test]
7581    fn consecutive_backslashes_roundtrip() {
7582        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a\\\\b"}]}]}"#;
7583        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7584        let jfm = adf_to_markdown(&adf).unwrap();
7585        let roundtripped = markdown_to_adf(&jfm).unwrap();
7586        let content = roundtripped.content[0].content.as_ref().unwrap();
7587        assert_eq!(
7588            content[0].text.as_deref(),
7589            Some(r"a\\b"),
7590            "consecutive backslashes should survive round-trip"
7591        );
7592    }
7593
7594    #[test]
7595    fn backslash_with_strong_mark_roundtrip() {
7596        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\Users","marks":[{"type":"strong"}]}]}]}"#;
7597        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7598        let jfm = adf_to_markdown(&adf).unwrap();
7599        let roundtripped = markdown_to_adf(&jfm).unwrap();
7600        let content = roundtripped.content[0].content.as_ref().unwrap();
7601        let strong_node = content.iter().find(|n| {
7602            n.marks
7603                .as_ref()
7604                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
7605        });
7606        assert!(strong_node.is_some(), "should have a strong-marked node");
7607        assert_eq!(strong_node.unwrap().text.as_deref(), Some(r"C:\Users"));
7608    }
7609
7610    #[test]
7611    fn backslash_with_code_mark_not_escaped() {
7612        // Code marks emit content verbatim — backslashes should NOT be escaped
7613        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\Users","marks":[{"type":"code"}]}]}]}"#;
7614        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7615        let jfm = adf_to_markdown(&adf).unwrap();
7616        assert_eq!(jfm.trim(), r"`C:\Users`");
7617        let roundtripped = markdown_to_adf(&jfm).unwrap();
7618        let content = roundtripped.content[0].content.as_ref().unwrap();
7619        let code_node = content.iter().find(|n| {
7620            n.marks
7621                .as_ref()
7622                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code"))
7623        });
7624        assert!(code_node.is_some(), "should have a code-marked node");
7625        assert_eq!(code_node.unwrap().text.as_deref(), Some(r"C:\Users"));
7626    }
7627
7628    #[test]
7629    fn backslash_before_special_chars_roundtrip() {
7630        // Backslash before characters that are themselves escaped (* ` :)
7631        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"\\*not bold\\*"}]}]}"#;
7632        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7633        let jfm = adf_to_markdown(&adf).unwrap();
7634        let roundtripped = markdown_to_adf(&jfm).unwrap();
7635        let content = roundtripped.content[0].content.as_ref().unwrap();
7636        assert_eq!(
7637            content[0].text.as_deref(),
7638            Some(r"\*not bold\*"),
7639            "backslash before special char should survive round-trip"
7640        );
7641    }
7642
7643    #[test]
7644    fn backslash_and_newline_in_text_roundtrip() {
7645        // Text with both backslashes and embedded newlines
7646        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\path\nline2"}]}]}"#;
7647        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7648        let jfm = adf_to_markdown(&adf).unwrap();
7649        let roundtripped = markdown_to_adf(&jfm).unwrap();
7650        let content = roundtripped.content[0].content.as_ref().unwrap();
7651        assert_eq!(
7652            content[0].text.as_deref(),
7653            Some("C:\\path\nline2"),
7654            "backslash and newline should both survive round-trip"
7655        );
7656    }
7657
7658    #[test]
7659    fn lone_backslash_roundtrip() {
7660        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a \\ b"}]}]}"#;
7661        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7662        let jfm = adf_to_markdown(&adf).unwrap();
7663        let roundtripped = markdown_to_adf(&jfm).unwrap();
7664        let content = roundtripped.content[0].content.as_ref().unwrap();
7665        assert_eq!(content[0].text.as_deref(), Some(r"a \ b"));
7666    }
7667
7668    #[test]
7669    fn trailing_backslash_in_text_roundtrip() {
7670        // A trailing backslash in text content (not a hardBreak) should round-trip
7671        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"end\\"}]}]}"#;
7672        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7673        let jfm = adf_to_markdown(&adf).unwrap();
7674        let roundtripped = markdown_to_adf(&jfm).unwrap();
7675        let content = roundtripped.content[0].content.as_ref().unwrap();
7676        assert_eq!(
7677            content[0].text.as_deref(),
7678            Some(r"end\"),
7679            "trailing backslash should survive round-trip"
7680        );
7681    }
7682
7683    #[test]
7684    fn escape_bare_urls_unit() {
7685        assert_eq!(escape_bare_urls("hello"), "hello");
7686        assert_eq!(escape_bare_urls(""), "");
7687        assert_eq!(
7688            escape_bare_urls("https://example.com"),
7689            r"\https://example.com"
7690        );
7691        assert_eq!(
7692            escape_bare_urls("http://example.com"),
7693            r"\http://example.com"
7694        );
7695        assert_eq!(
7696            escape_bare_urls("see https://a.com and https://b.com"),
7697            r"see \https://a.com and \https://b.com"
7698        );
7699        // "http" without "://" is not a URL scheme — leave untouched
7700        assert_eq!(escape_bare_urls("http header"), "http header");
7701        assert_eq!(escape_bare_urls("https is secure"), "https is secure");
7702    }
7703
7704    #[test]
7705    fn heading_not_valid_without_space() {
7706        // "#Title" without space should be a paragraph, not heading
7707        let doc = markdown_to_adf("#Title").unwrap();
7708        assert_eq!(doc.content[0].node_type, "paragraph");
7709    }
7710
7711    #[test]
7712    fn heading_level_too_high() {
7713        // ####### (7 hashes) is not a valid heading
7714        let doc = markdown_to_adf("####### Not a heading").unwrap();
7715        assert_eq!(doc.content[0].node_type, "paragraph");
7716    }
7717
7718    #[test]
7719    fn empty_document() {
7720        let doc = markdown_to_adf("").unwrap();
7721        assert!(doc.content.is_empty());
7722    }
7723
7724    #[test]
7725    fn only_blank_lines() {
7726        let doc = markdown_to_adf("\n\n\n").unwrap();
7727        assert!(doc.content.is_empty());
7728    }
7729
7730    #[test]
7731    fn code_block_unterminated() {
7732        // Code block without closing fence
7733        let md = "```rust\nfn main() {}";
7734        let doc = markdown_to_adf(md).unwrap();
7735        assert_eq!(doc.content[0].node_type, "codeBlock");
7736    }
7737
7738    #[test]
7739    fn mixed_document() {
7740        let md = "# Title\n\nA paragraph.\n\n- Item\n\n```\ncode\n```\n\n> quote\n\n---\n\n1. numbered\n";
7741        let doc = markdown_to_adf(md).unwrap();
7742        let types: Vec<&str> = doc.content.iter().map(|n| n.node_type.as_str()).collect();
7743        assert_eq!(
7744            types,
7745            vec![
7746                "heading",
7747                "paragraph",
7748                "bulletList",
7749                "codeBlock",
7750                "blockquote",
7751                "rule",
7752                "orderedList",
7753            ]
7754        );
7755    }
7756
7757    // ── Additional adf_to_markdown tests ───────────────────────────────
7758
7759    #[test]
7760    fn adf_ordered_list_to_markdown() {
7761        let doc = AdfDocument {
7762            version: 1,
7763            doc_type: "doc".to_string(),
7764            content: vec![AdfNode::ordered_list(
7765                vec![
7766                    AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("First")])]),
7767                    AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("Second")])]),
7768                ],
7769                None,
7770            )],
7771        };
7772        let md = adf_to_markdown(&doc).unwrap();
7773        assert!(md.contains("1. First"));
7774        assert!(md.contains("2. Second"));
7775    }
7776
7777    #[test]
7778    fn adf_ordered_list_custom_start() {
7779        let doc = AdfDocument {
7780            version: 1,
7781            doc_type: "doc".to_string(),
7782            content: vec![AdfNode::ordered_list(
7783                vec![AdfNode::list_item(vec![AdfNode::paragraph(vec![
7784                    AdfNode::text("Third"),
7785                ])])],
7786                Some(3),
7787            )],
7788        };
7789        let md = adf_to_markdown(&doc).unwrap();
7790        assert!(md.contains("3. Third"));
7791    }
7792
7793    #[test]
7794    fn adf_blockquote_to_markdown() {
7795        let doc = AdfDocument {
7796            version: 1,
7797            doc_type: "doc".to_string(),
7798            content: vec![AdfNode::blockquote(vec![AdfNode::paragraph(vec![
7799                AdfNode::text("A quote"),
7800            ])])],
7801        };
7802        let md = adf_to_markdown(&doc).unwrap();
7803        assert!(md.contains("> A quote"));
7804    }
7805
7806    #[test]
7807    fn adf_table_to_markdown() {
7808        let doc = AdfDocument {
7809            version: 1,
7810            doc_type: "doc".to_string(),
7811            content: vec![AdfNode::table(vec![
7812                AdfNode::table_row(vec![
7813                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("Name")])]),
7814                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("Value")])]),
7815                ]),
7816                AdfNode::table_row(vec![
7817                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("a")])]),
7818                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("1")])]),
7819                ]),
7820            ])],
7821        };
7822        let md = adf_to_markdown(&doc).unwrap();
7823        assert!(md.contains("| Name | Value |"));
7824        assert!(md.contains("| --- | --- |"));
7825        assert!(md.contains("| a | 1 |"));
7826    }
7827
7828    #[test]
7829    fn adf_media_to_markdown() {
7830        let doc = AdfDocument {
7831            version: 1,
7832            doc_type: "doc".to_string(),
7833            content: vec![AdfNode::media_single(
7834                "https://example.com/img.png",
7835                Some("Alt"),
7836            )],
7837        };
7838        let md = adf_to_markdown(&doc).unwrap();
7839        assert!(md.contains("![Alt](https://example.com/img.png)"));
7840    }
7841
7842    #[test]
7843    fn adf_media_no_alt_to_markdown() {
7844        let doc = AdfDocument {
7845            version: 1,
7846            doc_type: "doc".to_string(),
7847            content: vec![AdfNode::media_single("https://example.com/img.png", None)],
7848        };
7849        let md = adf_to_markdown(&doc).unwrap();
7850        assert!(md.contains("![](https://example.com/img.png)"));
7851    }
7852
7853    #[test]
7854    fn adf_italic_to_markdown() {
7855        let doc = AdfDocument {
7856            version: 1,
7857            doc_type: "doc".to_string(),
7858            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7859                "emphasis",
7860                vec![AdfMark::em()],
7861            )])],
7862        };
7863        let md = adf_to_markdown(&doc).unwrap();
7864        assert_eq!(md.trim(), "*emphasis*");
7865    }
7866
7867    #[test]
7868    fn adf_strikethrough_to_markdown() {
7869        let doc = AdfDocument {
7870            version: 1,
7871            doc_type: "doc".to_string(),
7872            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7873                "deleted",
7874                vec![AdfMark::strike()],
7875            )])],
7876        };
7877        let md = adf_to_markdown(&doc).unwrap();
7878        assert_eq!(md.trim(), "~~deleted~~");
7879    }
7880
7881    #[test]
7882    fn adf_inline_code_to_markdown() {
7883        let doc = AdfDocument {
7884            version: 1,
7885            doc_type: "doc".to_string(),
7886            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7887                "code",
7888                vec![AdfMark::code()],
7889            )])],
7890        };
7891        let md = adf_to_markdown(&doc).unwrap();
7892        assert_eq!(md.trim(), "`code`");
7893    }
7894
7895    #[test]
7896    fn adf_code_with_link_to_markdown() {
7897        let doc = AdfDocument {
7898            version: 1,
7899            doc_type: "doc".to_string(),
7900            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7901                "func",
7902                vec![AdfMark::code(), AdfMark::link("https://example.com")],
7903            )])],
7904        };
7905        let md = adf_to_markdown(&doc).unwrap();
7906        assert_eq!(md.trim(), "[`func`](https://example.com)");
7907    }
7908
7909    #[test]
7910    fn adf_bold_italic_to_markdown() {
7911        let doc = AdfDocument {
7912            version: 1,
7913            doc_type: "doc".to_string(),
7914            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7915                "both",
7916                vec![AdfMark::strong(), AdfMark::em()],
7917            )])],
7918        };
7919        let md = adf_to_markdown(&doc).unwrap();
7920        assert_eq!(md.trim(), "***both***");
7921    }
7922
7923    #[test]
7924    fn adf_bold_link_to_markdown() {
7925        let doc = AdfDocument {
7926            version: 1,
7927            doc_type: "doc".to_string(),
7928            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7929                "bold link",
7930                vec![AdfMark::strong(), AdfMark::link("https://example.com")],
7931            )])],
7932        };
7933        let md = adf_to_markdown(&doc).unwrap();
7934        assert_eq!(md.trim(), "**[bold link](https://example.com)**");
7935    }
7936
7937    #[test]
7938    fn adf_strikethrough_bold_to_markdown() {
7939        let doc = AdfDocument {
7940            version: 1,
7941            doc_type: "doc".to_string(),
7942            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7943                "struck",
7944                vec![AdfMark::strike(), AdfMark::strong()],
7945            )])],
7946        };
7947        let md = adf_to_markdown(&doc).unwrap();
7948        assert_eq!(md.trim(), "~~**struck**~~");
7949    }
7950
7951    #[test]
7952    fn adf_hard_break_to_markdown() {
7953        let doc = AdfDocument {
7954            version: 1,
7955            doc_type: "doc".to_string(),
7956            content: vec![AdfNode::paragraph(vec![
7957                AdfNode::text("Line 1"),
7958                AdfNode::hard_break(),
7959                AdfNode::text("Line 2"),
7960            ])],
7961        };
7962        let md = adf_to_markdown(&doc).unwrap();
7963        assert!(md.contains("Line 1\\\n  Line 2"));
7964    }
7965
7966    #[test]
7967    #[test]
7968    fn adf_unsupported_inline_to_markdown() {
7969        let doc = AdfDocument {
7970            version: 1,
7971            doc_type: "doc".to_string(),
7972            content: vec![AdfNode::paragraph(vec![AdfNode {
7973                node_type: "unknownInline".to_string(),
7974                attrs: None,
7975                content: None,
7976                text: None,
7977                marks: None,
7978                local_id: None,
7979                parameters: None,
7980            }])],
7981        };
7982        let md = adf_to_markdown(&doc).unwrap();
7983        assert!(md.contains("<!-- unsupported inline: unknownInline -->"));
7984    }
7985
7986    // ── mediaInline tests (issue #476) ─────────────────────────────────
7987
7988    #[test]
7989    fn adf_media_inline_to_markdown() {
7990        let doc = AdfDocument {
7991            version: 1,
7992            doc_type: "doc".to_string(),
7993            content: vec![AdfNode::paragraph(vec![
7994                AdfNode::text("see "),
7995                AdfNode::media_inline(serde_json::json!({
7996                    "type": "image",
7997                    "id": "abcdef01-2345-6789-abcd-abcdef012345",
7998                    "collection": "contentId-111111",
7999                    "width": 200,
8000                    "height": 100
8001                })),
8002                AdfNode::text(" for details"),
8003            ])],
8004        };
8005        let md = adf_to_markdown(&doc).unwrap();
8006        assert!(md.contains(":media-inline[]{"), "got: {md}");
8007        assert!(md.contains("type=image"));
8008        assert!(md.contains("id=abcdef01-2345-6789-abcd-abcdef012345"));
8009        assert!(md.contains("collection=contentId-111111"));
8010        assert!(md.contains("width=200"));
8011        assert!(md.contains("height=100"));
8012        assert!(!md.contains("<!-- unsupported inline"));
8013    }
8014
8015    #[test]
8016    fn media_inline_round_trip() {
8017        let doc = AdfDocument {
8018            version: 1,
8019            doc_type: "doc".to_string(),
8020            content: vec![AdfNode::paragraph(vec![
8021                AdfNode::text("see "),
8022                AdfNode::media_inline(serde_json::json!({
8023                    "type": "image",
8024                    "id": "abcdef01-2345-6789-abcd-abcdef012345",
8025                    "collection": "contentId-111111",
8026                    "width": 200,
8027                    "height": 100
8028                })),
8029                AdfNode::text(" for details"),
8030            ])],
8031        };
8032        let md = adf_to_markdown(&doc).unwrap();
8033        let rt = markdown_to_adf(&md).unwrap();
8034
8035        let content = rt.content[0].content.as_ref().unwrap();
8036        assert_eq!(content[0].text.as_deref(), Some("see "));
8037        assert_eq!(content[1].node_type, "mediaInline");
8038        let attrs = content[1].attrs.as_ref().unwrap();
8039        assert_eq!(attrs["type"], "image");
8040        assert_eq!(attrs["id"], "abcdef01-2345-6789-abcd-abcdef012345");
8041        assert_eq!(attrs["collection"], "contentId-111111");
8042        assert_eq!(attrs["width"], 200);
8043        assert_eq!(attrs["height"], 100);
8044        assert_eq!(content[2].text.as_deref(), Some(" for details"));
8045    }
8046
8047    #[test]
8048    fn media_inline_external_url_round_trip() {
8049        let doc = AdfDocument {
8050            version: 1,
8051            doc_type: "doc".to_string(),
8052            content: vec![AdfNode::paragraph(vec![AdfNode::media_inline(
8053                serde_json::json!({
8054                    "type": "external",
8055                    "url": "https://example.com/image.png",
8056                    "alt": "example",
8057                    "width": 400,
8058                    "height": 300
8059                }),
8060            )])],
8061        };
8062        let md = adf_to_markdown(&doc).unwrap();
8063        let rt = markdown_to_adf(&md).unwrap();
8064
8065        let content = rt.content[0].content.as_ref().unwrap();
8066        assert_eq!(content[0].node_type, "mediaInline");
8067        let attrs = content[0].attrs.as_ref().unwrap();
8068        assert_eq!(attrs["type"], "external");
8069        assert_eq!(attrs["url"], "https://example.com/image.png");
8070        assert_eq!(attrs["alt"], "example");
8071        assert_eq!(attrs["width"], 400);
8072        assert_eq!(attrs["height"], 300);
8073    }
8074
8075    #[test]
8076    fn media_inline_minimal_attrs() {
8077        let doc = AdfDocument {
8078            version: 1,
8079            doc_type: "doc".to_string(),
8080            content: vec![AdfNode::paragraph(vec![AdfNode::media_inline(
8081                serde_json::json!({"type": "file", "id": "abc-123"}),
8082            )])],
8083        };
8084        let md = adf_to_markdown(&doc).unwrap();
8085        let rt = markdown_to_adf(&md).unwrap();
8086
8087        let content = rt.content[0].content.as_ref().unwrap();
8088        assert_eq!(content[0].node_type, "mediaInline");
8089        let attrs = content[0].attrs.as_ref().unwrap();
8090        assert_eq!(attrs["type"], "file");
8091        assert_eq!(attrs["id"], "abc-123");
8092    }
8093
8094    #[test]
8095    fn media_inline_from_issue_476_reproducer() {
8096        // Exact reproducer from issue #476
8097        let adf_json: serde_json::Value = serde_json::json!({
8098            "type": "doc",
8099            "version": 1,
8100            "content": [
8101                {
8102                    "type": "paragraph",
8103                    "content": [
8104                        {"type": "text", "text": "see "},
8105                        {
8106                            "type": "mediaInline",
8107                            "attrs": {
8108                                "collection": "contentId-111111",
8109                                "height": 100,
8110                                "id": "abcdef01-2345-6789-abcd-abcdef012345",
8111                                "localId": "aabbccdd-1234-5678-abcd-aabbccdd1234",
8112                                "type": "image",
8113                                "width": 200
8114                            }
8115                        },
8116                        {"type": "text", "text": " for details"}
8117                    ]
8118                }
8119            ]
8120        });
8121        let doc: AdfDocument = serde_json::from_value(adf_json).unwrap();
8122        let md = adf_to_markdown(&doc).unwrap();
8123        assert!(
8124            !md.contains("<!-- unsupported inline"),
8125            "mediaInline should not be unsupported; got: {md}"
8126        );
8127
8128        let rt = markdown_to_adf(&md).unwrap();
8129        let content = rt.content[0].content.as_ref().unwrap();
8130        assert_eq!(content[1].node_type, "mediaInline");
8131        let attrs = content[1].attrs.as_ref().unwrap();
8132        assert_eq!(attrs["type"], "image");
8133        assert_eq!(attrs["id"], "abcdef01-2345-6789-abcd-abcdef012345");
8134        assert_eq!(attrs["collection"], "contentId-111111");
8135        assert_eq!(attrs["width"], 200);
8136        assert_eq!(attrs["height"], 100);
8137        assert_eq!(attrs["localId"], "aabbccdd-1234-5678-abcd-aabbccdd1234");
8138    }
8139
8140    #[test]
8141    fn emoji_shortcode() {
8142        let doc = markdown_to_adf("Hello :wave: world").unwrap();
8143        let content = doc.content[0].content.as_ref().unwrap();
8144        assert_eq!(content[0].text.as_deref(), Some("Hello "));
8145        assert_eq!(content[1].node_type, "emoji");
8146        assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":wave:");
8147        assert_eq!(content[2].text.as_deref(), Some(" world"));
8148    }
8149
8150    #[test]
8151    fn adf_emoji_to_markdown() {
8152        let doc = AdfDocument {
8153            version: 1,
8154            doc_type: "doc".to_string(),
8155            content: vec![AdfNode::paragraph(vec![AdfNode::emoji("thumbsup")])],
8156        };
8157        let md = adf_to_markdown(&doc).unwrap();
8158        assert!(md.contains(":thumbsup:"));
8159    }
8160
8161    #[test]
8162    fn adf_emoji_with_colon_prefix_to_markdown() {
8163        // JIRA stores shortName as ":thumbsup:" with colons
8164        let doc = AdfDocument {
8165            version: 1,
8166            doc_type: "doc".to_string(),
8167            content: vec![AdfNode::paragraph(vec![AdfNode {
8168                node_type: "emoji".to_string(),
8169                attrs: Some(serde_json::json!({"shortName": ":thumbsup:"})),
8170                content: None,
8171                text: None,
8172                marks: None,
8173                local_id: None,
8174                parameters: None,
8175            }])],
8176        };
8177        let md = adf_to_markdown(&doc).unwrap();
8178        assert!(md.contains(":thumbsup:"));
8179        // Should not produce ::thumbsup:: (double colons)
8180        assert!(!md.contains("::thumbsup::"));
8181    }
8182
8183    #[test]
8184    fn round_trip_emoji() {
8185        let md = "Hello :wave: world\n";
8186        let doc = markdown_to_adf(md).unwrap();
8187        let result = adf_to_markdown(&doc).unwrap();
8188        assert!(result.contains(":wave:"));
8189    }
8190
8191    #[test]
8192    fn emoji_with_id_and_text_round_trips() {
8193        let doc = AdfDocument {
8194            version: 1,
8195            doc_type: "doc".to_string(),
8196            content: vec![AdfNode::paragraph(vec![AdfNode {
8197                node_type: "emoji".to_string(),
8198                attrs: Some(
8199                    serde_json::json!({"shortName": ":check_mark:", "id": "2705", "text": "✅"}),
8200                ),
8201                content: None,
8202                text: None,
8203                marks: None,
8204                local_id: None,
8205                parameters: None,
8206            }])],
8207        };
8208        let md = adf_to_markdown(&doc).unwrap();
8209        assert!(md.contains(":check_mark:"), "shortcode present: {md}");
8210        assert!(md.contains("id="), "id attr present: {md}");
8211        assert!(md.contains("text="), "text attr present: {md}");
8212
8213        // Round-trip back to ADF
8214        let round_tripped = markdown_to_adf(&md).unwrap();
8215        let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
8216        let attrs = emoji.attrs.as_ref().unwrap();
8217        assert_eq!(attrs["shortName"], ":check_mark:");
8218        assert_eq!(attrs["id"], "2705");
8219        assert_eq!(attrs["text"], "✅");
8220    }
8221
8222    #[test]
8223    fn emoji_without_extra_attrs_still_works() {
8224        let md = "Hello :wave: world\n";
8225        let doc = markdown_to_adf(md).unwrap();
8226        let emoji = &doc.content[0].content.as_ref().unwrap()[1];
8227        assert_eq!(emoji.attrs.as_ref().unwrap()["shortName"], ":wave:");
8228        // No id or text attrs when not provided
8229        assert!(emoji.attrs.as_ref().unwrap().get("id").is_none());
8230    }
8231
8232    #[test]
8233    fn emoji_shortname_preserves_colons_round_trip() {
8234        // Issue #362: emoji shortName colons stripped during round-trip
8235        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8236          {"type":"emoji","attrs":{"shortName":":cross_mark:","id":"atlassian-cross_mark","text":"❌"}}
8237        ]}]}"#;
8238        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8239
8240        // ADF → markdown → ADF round-trip
8241        let md = adf_to_markdown(&doc).unwrap();
8242        let round_tripped = markdown_to_adf(&md).unwrap();
8243        let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
8244        let attrs = emoji.attrs.as_ref().unwrap();
8245        assert_eq!(
8246            attrs["shortName"], ":cross_mark:",
8247            "shortName should preserve colons, got: {}",
8248            attrs["shortName"]
8249        );
8250        assert_eq!(attrs["id"], "atlassian-cross_mark");
8251        assert_eq!(attrs["text"], "❌");
8252    }
8253
8254    #[test]
8255    fn emoji_shortname_without_colons_preserved() {
8256        // Issue #379: shortName without colons should not gain colons
8257        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8258          {"type":"emoji","attrs":{"shortName":"white_check_mark","id":"2705","text":"✅"}}
8259        ]}]}"#;
8260        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8261        let md = adf_to_markdown(&doc).unwrap();
8262        let round_tripped = markdown_to_adf(&md).unwrap();
8263        let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
8264        let attrs = emoji.attrs.as_ref().unwrap();
8265        assert_eq!(
8266            attrs["shortName"], "white_check_mark",
8267            "shortName without colons should stay without colons, got: {}",
8268            attrs["shortName"]
8269        );
8270    }
8271
8272    #[test]
8273    fn colon_in_text_not_emoji() {
8274        // A lone colon should not trigger emoji parsing
8275        let doc = markdown_to_adf("Time is 10:30 today").unwrap();
8276        let content = doc.content[0].content.as_ref().unwrap();
8277        assert_eq!(content.len(), 1);
8278        assert_eq!(content[0].node_type, "text");
8279    }
8280
8281    #[test]
8282    fn text_with_shortcode_pattern_round_trips_as_text() {
8283        // Issue #450: `:fire:` in a text node must not become an emoji node
8284        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Alert :fire: triggered on pod:pod42"}]}]}"#;
8285        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8286
8287        let md = adf_to_markdown(&doc).unwrap();
8288        let round_tripped = markdown_to_adf(&md).unwrap();
8289        let content = round_tripped.content[0].content.as_ref().unwrap();
8290
8291        assert_eq!(
8292            content.len(),
8293            1,
8294            "should be a single text node, got: {content:?}"
8295        );
8296        assert_eq!(content[0].node_type, "text");
8297        assert_eq!(
8298            content[0].text.as_deref().unwrap(),
8299            "Alert :fire: triggered on pod:pod42"
8300        );
8301    }
8302
8303    #[test]
8304    fn double_colon_pattern_round_trips_as_text() {
8305        // Issue #450: `::Active::` should not be parsed as emoji `:Active:`
8306        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Status::Active::Running"}]}]}"#;
8307        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8308
8309        let md = adf_to_markdown(&doc).unwrap();
8310        let round_tripped = markdown_to_adf(&md).unwrap();
8311        let content = round_tripped.content[0].content.as_ref().unwrap();
8312
8313        assert_eq!(
8314            content.len(),
8315            1,
8316            "should be a single text node, got: {content:?}"
8317        );
8318        assert_eq!(content[0].node_type, "text");
8319        assert_eq!(
8320            content[0].text.as_deref().unwrap(),
8321            "Status::Active::Running"
8322        );
8323    }
8324
8325    #[test]
8326    fn real_emoji_node_still_round_trips() {
8327        // Ensure actual emoji ADF nodes still work after the escaping fix
8328        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8329          {"type":"text","text":"Hello "},
8330          {"type":"emoji","attrs":{"shortName":":fire:","id":"1f525","text":"🔥"}},
8331          {"type":"text","text":" world"}
8332        ]}]}"#;
8333        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8334
8335        let md = adf_to_markdown(&doc).unwrap();
8336        let round_tripped = markdown_to_adf(&md).unwrap();
8337        let content = round_tripped.content[0].content.as_ref().unwrap();
8338
8339        // Should have: text("Hello ") + emoji(:fire:) + text(" world")
8340        assert_eq!(content.len(), 3, "should have 3 nodes: {content:?}");
8341        assert_eq!(content[0].text.as_deref(), Some("Hello "));
8342        assert_eq!(content[1].node_type, "emoji");
8343        assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":fire:");
8344        assert_eq!(content[2].text.as_deref(), Some(" world"));
8345    }
8346
8347    #[test]
8348    fn combined_emoji_shortname_round_trips_as_single_node() {
8349        // Issue #576: an emoji node whose shortName is a combination of two
8350        // shortcodes (e.g. ":slightly_smiling_face::bow:") must survive the
8351        // ADF → markdown → ADF round-trip as a single emoji node rather than
8352        // being split into two.
8353        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8354          {"type":"text","text":"Thanks for the help "},
8355          {"type":"emoji","attrs":{"shortName":":slightly_smiling_face::bow:","id":"","text":""}}
8356        ]}]}"#;
8357        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8358
8359        let md = adf_to_markdown(&doc).unwrap();
8360        let round_tripped = markdown_to_adf(&md).unwrap();
8361        let content = round_tripped.content[0].content.as_ref().unwrap();
8362
8363        assert_eq!(
8364            content.len(),
8365            2,
8366            "should have text + single combined emoji: {content:?}"
8367        );
8368        assert_eq!(content[0].text.as_deref(), Some("Thanks for the help "));
8369        assert_eq!(content[1].node_type, "emoji");
8370        let attrs = content[1].attrs.as_ref().unwrap();
8371        assert_eq!(attrs["shortName"], ":slightly_smiling_face::bow:");
8372        assert_eq!(attrs["id"], "");
8373        assert_eq!(attrs["text"], "");
8374    }
8375
8376    #[test]
8377    fn triple_combined_emoji_shortname_round_trips_as_single_node() {
8378        // Three-part combined shortName must also survive round-trip.
8379        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8380          {"type":"emoji","attrs":{"shortName":":a::b::c:","id":"x","text":""}}
8381        ]}]}"#;
8382        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8383
8384        let md = adf_to_markdown(&doc).unwrap();
8385        let round_tripped = markdown_to_adf(&md).unwrap();
8386        let content = round_tripped.content[0].content.as_ref().unwrap();
8387
8388        assert_eq!(content.len(), 1, "should be single emoji: {content:?}");
8389        assert_eq!(content[0].node_type, "emoji");
8390        let attrs = content[0].attrs.as_ref().unwrap();
8391        assert_eq!(attrs["shortName"], ":a::b::c:");
8392        assert_eq!(attrs["id"], "x");
8393    }
8394
8395    #[test]
8396    fn consecutive_emojis_remain_separate_nodes() {
8397        // Two independent emoji nodes (each with their own directive) must
8398        // remain two separate nodes — the combined-chain logic must not
8399        // swallow the second emoji.
8400        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8401          {"type":"emoji","attrs":{"shortName":":fire:","id":"1f525","text":"🔥"}},
8402          {"type":"emoji","attrs":{"shortName":":water:","id":"1f4a7","text":"💧"}}
8403        ]}]}"#;
8404        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8405
8406        let md = adf_to_markdown(&doc).unwrap();
8407        let round_tripped = markdown_to_adf(&md).unwrap();
8408        let content = round_tripped.content[0].content.as_ref().unwrap();
8409
8410        assert_eq!(content.len(), 2, "should be two emoji nodes: {content:?}");
8411        assert_eq!(content[0].node_type, "emoji");
8412        assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":fire:");
8413        assert_eq!(content[1].node_type, "emoji");
8414        assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":water:");
8415    }
8416
8417    #[test]
8418    fn adjacent_shortcodes_without_directive_parse_as_two_emojis() {
8419        // Raw markdown with two adjacent shortcodes and no directive should
8420        // still parse as two separate emoji nodes.
8421        let md = ":fire::water:";
8422        let doc = markdown_to_adf(md).unwrap();
8423        let content = doc.content[0].content.as_ref().unwrap();
8424
8425        assert_eq!(content.len(), 2, "should be two emojis: {content:?}");
8426        assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":fire:");
8427        assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":water:");
8428    }
8429
8430    #[test]
8431    fn combined_emoji_shortname_preserves_local_id() {
8432        // The directive's localId should be preserved when the chain is
8433        // collapsed into a single emoji node.
8434        let md = r#":a::b:{shortName=":a::b:" id="x" text="y" localId="abc"}"#;
8435        let doc = markdown_to_adf(md).unwrap();
8436        let content = doc.content[0].content.as_ref().unwrap();
8437
8438        assert_eq!(content.len(), 1, "should be single emoji: {content:?}");
8439        let attrs = content[0].attrs.as_ref().unwrap();
8440        assert_eq!(attrs["shortName"], ":a::b:");
8441        assert_eq!(attrs["id"], "x");
8442        assert_eq!(attrs["text"], "y");
8443        assert_eq!(attrs["localId"], "abc");
8444    }
8445
8446    #[test]
8447    fn text_shortcode_with_marks_round_trips() {
8448        // Bold text containing an emoji-like shortcode should round-trip as bold text
8449        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8450          {"type":"text","text":"Alert :fire: triggered","marks":[{"type":"strong"}]}
8451        ]}]}"#;
8452        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8453
8454        let md = adf_to_markdown(&doc).unwrap();
8455        let round_tripped = markdown_to_adf(&md).unwrap();
8456        let content = round_tripped.content[0].content.as_ref().unwrap();
8457
8458        assert_eq!(
8459            content.len(),
8460            1,
8461            "should be single bold text node: {content:?}"
8462        );
8463        assert_eq!(content[0].node_type, "text");
8464        assert_eq!(
8465            content[0].text.as_deref().unwrap(),
8466            "Alert :fire: triggered"
8467        );
8468        assert!(content[0]
8469            .marks
8470            .as_ref()
8471            .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong")));
8472    }
8473
8474    #[test]
8475    fn mixed_emoji_node_and_text_shortcode_round_trips() {
8476        // Real emoji node adjacent to text containing a shortcode-like pattern
8477        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8478          {"type":"emoji","attrs":{"shortName":":wave:","id":"1f44b","text":"👋"}},
8479          {"type":"text","text":" says :hello: to you"}
8480        ]}]}"#;
8481        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8482
8483        let md = adf_to_markdown(&doc).unwrap();
8484        let round_tripped = markdown_to_adf(&md).unwrap();
8485        let content = round_tripped.content[0].content.as_ref().unwrap();
8486
8487        // Should be: emoji(:wave:) + text(" says :hello: to you")
8488        assert_eq!(content.len(), 2, "should have 2 nodes: {content:?}");
8489        assert_eq!(content[0].node_type, "emoji");
8490        assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":wave:");
8491        assert_eq!(content[1].node_type, "text");
8492        assert_eq!(content[1].text.as_deref().unwrap(), " says :hello: to you");
8493    }
8494
8495    #[test]
8496    fn code_block_with_shortcode_pattern_round_trips() {
8497        // Issue #552: Code block content containing `::Name::` patterns must not
8498        // be re-parsed as emoji shortcodes.
8499        let adf_json = r#"{"version":1,"type":"doc","content":[
8500          {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8501            {"type":"text","text":"module Foo::Bar::Baz\n  def hello\n    puts 'world'\n  end\nend"}
8502          ]}
8503        ]}"#;
8504        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8505
8506        let md = adf_to_markdown(&doc).unwrap();
8507        let round_tripped = markdown_to_adf(&md).unwrap();
8508
8509        assert_eq!(
8510            round_tripped.content.len(),
8511            1,
8512            "should be a single codeBlock"
8513        );
8514        let cb = &round_tripped.content[0];
8515        assert_eq!(cb.node_type, "codeBlock");
8516        let content = cb.content.as_ref().expect("codeBlock content");
8517        assert_eq!(
8518            content.len(),
8519            1,
8520            "should be a single text node: {content:?}"
8521        );
8522        assert_eq!(content[0].node_type, "text");
8523        assert_eq!(
8524            content[0].text.as_deref().unwrap(),
8525            "module Foo::Bar::Baz\n  def hello\n    puts 'world'\n  end\nend"
8526        );
8527        assert!(
8528            content.iter().all(|n| n.node_type != "emoji"),
8529            "no emoji nodes should be present, got: {content:?}"
8530        );
8531    }
8532
8533    #[test]
8534    fn code_block_with_exact_namespaced_shortcode_pattern_round_trips() {
8535        // Issue #552: Use a namespaced pattern modeled on the bug report.
8536        let adf_json = r#"{"version":1,"type":"doc","content":[
8537          {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8538            {"type":"text","text":"class ZBC::Acme::PlanType::Professional < Base"}
8539          ]}
8540        ]}"#;
8541        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8542
8543        let md = adf_to_markdown(&doc).unwrap();
8544        let round_tripped = markdown_to_adf(&md).unwrap();
8545
8546        let cb = &round_tripped.content[0];
8547        assert_eq!(cb.node_type, "codeBlock");
8548        let content = cb.content.as_ref().expect("codeBlock content");
8549        assert_eq!(content.len(), 1, "should be a single text node");
8550        assert_eq!(
8551            content[0].text.as_deref().unwrap(),
8552            "class ZBC::Acme::PlanType::Professional < Base"
8553        );
8554    }
8555
8556    #[test]
8557    fn code_block_with_literal_shortcode_round_trips() {
8558        // Issue #552: Content that is exactly a shortcode (`:fire:`) inside a
8559        // code block should survive the round-trip as literal text, not emoji.
8560        let adf_json = r#"{"version":1,"type":"doc","content":[
8561          {"type":"codeBlock","attrs":{"language":"text"},"content":[
8562            {"type":"text","text":":fire: :wave: :thumbsup:"}
8563          ]}
8564        ]}"#;
8565        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8566
8567        let md = adf_to_markdown(&doc).unwrap();
8568        let round_tripped = markdown_to_adf(&md).unwrap();
8569
8570        let cb = &round_tripped.content[0];
8571        assert_eq!(cb.node_type, "codeBlock");
8572        let content = cb.content.as_ref().expect("codeBlock content");
8573        assert_eq!(
8574            content.len(),
8575            1,
8576            "should be a single text node: {content:?}"
8577        );
8578        assert_eq!(content[0].node_type, "text");
8579        assert_eq!(
8580            content[0].text.as_deref().unwrap(),
8581            ":fire: :wave: :thumbsup:"
8582        );
8583    }
8584
8585    #[test]
8586    fn inline_code_with_shortcode_pattern_round_trips() {
8587        // Issue #552: Inline code containing `::Name::` patterns must not be
8588        // re-parsed as emoji shortcodes.
8589        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8590          {"type":"text","text":"See "},
8591          {"type":"text","text":"Foo::Bar::Baz","marks":[{"type":"code"}]},
8592          {"type":"text","text":" for details"}
8593        ]}]}"#;
8594        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8595
8596        let md = adf_to_markdown(&doc).unwrap();
8597        let round_tripped = markdown_to_adf(&md).unwrap();
8598        let content = round_tripped.content[0].content.as_ref().unwrap();
8599
8600        assert_eq!(content.len(), 3, "should have 3 text nodes: {content:?}");
8601        assert_eq!(content[0].text.as_deref(), Some("See "));
8602        assert_eq!(content[1].text.as_deref(), Some("Foo::Bar::Baz"));
8603        assert!(content[1]
8604            .marks
8605            .as_ref()
8606            .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
8607        assert_eq!(content[2].text.as_deref(), Some(" for details"));
8608        assert!(
8609            content.iter().all(|n| n.node_type != "emoji"),
8610            "no emoji nodes should be present"
8611        );
8612    }
8613
8614    #[test]
8615    fn inline_code_with_literal_shortcode_round_trips() {
8616        // Issue #552: Inline code whose content is exactly a shortcode must be
8617        // preserved as code, not converted to an emoji.
8618        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8619          {"type":"text","text":":fire:","marks":[{"type":"code"}]}
8620        ]}]}"#;
8621        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8622
8623        let md = adf_to_markdown(&doc).unwrap();
8624        let round_tripped = markdown_to_adf(&md).unwrap();
8625        let content = round_tripped.content[0].content.as_ref().unwrap();
8626
8627        assert_eq!(
8628            content.len(),
8629            1,
8630            "should be a single code node: {content:?}"
8631        );
8632        assert_eq!(content[0].node_type, "text");
8633        assert_eq!(content[0].text.as_deref(), Some(":fire:"));
8634        assert!(content[0]
8635            .marks
8636            .as_ref()
8637            .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
8638    }
8639
8640    #[test]
8641    fn code_block_in_list_with_shortcode_pattern_round_trips() {
8642        // Issue #552: A code block containing shortcode-like patterns nested in
8643        // a list should still survive round-trip without emoji corruption.
8644        let adf_json = r#"{"version":1,"type":"doc","content":[
8645          {"type":"bulletList","content":[
8646            {"type":"listItem","content":[
8647              {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8648                {"type":"text","text":"Foo::Bar::Baz"}
8649              ]}
8650            ]}
8651          ]}
8652        ]}"#;
8653        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8654
8655        let md = adf_to_markdown(&doc).unwrap();
8656        let round_tripped = markdown_to_adf(&md).unwrap();
8657
8658        let list = &round_tripped.content[0];
8659        assert_eq!(list.node_type, "bulletList");
8660        let item = &list.content.as_ref().unwrap()[0];
8661        assert_eq!(item.node_type, "listItem");
8662        let cb = &item.content.as_ref().unwrap()[0];
8663        assert_eq!(cb.node_type, "codeBlock");
8664        let cb_content = cb.content.as_ref().unwrap();
8665        assert_eq!(cb_content[0].text.as_deref(), Some("Foo::Bar::Baz"));
8666        assert_eq!(cb_content[0].node_type, "text");
8667    }
8668
8669    #[test]
8670    fn code_block_with_unicode_shortcode_pattern_round_trips() {
8671        // Issue #552: Code block content with non-ASCII colon-delimited words
8672        // (e.g. CJK or accented characters) must round-trip without splitting
8673        // or emoji corruption.
8674        let adf_json = r#"{"version":1,"type":"doc","content":[
8675          {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8676            {"type":"text","text":"class ZBC::配置::Production < Base"}
8677          ]}
8678        ]}"#;
8679        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8680
8681        let md = adf_to_markdown(&doc).unwrap();
8682        let round_tripped = markdown_to_adf(&md).unwrap();
8683
8684        let cb = &round_tripped.content[0];
8685        assert_eq!(cb.node_type, "codeBlock");
8686        let content = cb.content.as_ref().expect("codeBlock content");
8687        assert_eq!(content.len(), 1);
8688        assert_eq!(
8689            content[0].text.as_deref().unwrap(),
8690            "class ZBC::配置::Production < Base"
8691        );
8692    }
8693
8694    #[test]
8695    fn list_item_hardbreak_then_code_block_round_trips() {
8696        // Issue #552: A list item whose first paragraph ends with a hardBreak
8697        // followed by a codeBlock must round-trip correctly.  Previously, the
8698        // hardBreak's `\` continuation swallowed the 2-space-indented code
8699        // fence line, turning the whole block into a paragraph where `::Bar::`
8700        // was re-parsed as an emoji.
8701        let adf_json = r#"{"version":1,"type":"doc","content":[
8702          {"type":"bulletList","content":[
8703            {"type":"listItem","content":[
8704              {"type":"paragraph","content":[
8705                {"type":"text","text":"Consider removing this check."},
8706                {"type":"hardBreak"}
8707              ]},
8708              {"type":"codeBlock","content":[
8709                {"type":"text","text":"x = Foo::Bar::Baz.new"}
8710              ]}
8711            ]}
8712          ]}
8713        ]}"#;
8714        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8715
8716        let md = adf_to_markdown(&doc).unwrap();
8717        let round_tripped = markdown_to_adf(&md).unwrap();
8718
8719        let list = &round_tripped.content[0];
8720        assert_eq!(list.node_type, "bulletList");
8721        let item = &list.content.as_ref().unwrap()[0];
8722        assert_eq!(item.node_type, "listItem");
8723        let item_content = item.content.as_ref().unwrap();
8724        assert_eq!(
8725            item_content.len(),
8726            2,
8727            "listItem should have paragraph + codeBlock, got: {item_content:?}"
8728        );
8729        assert_eq!(item_content[0].node_type, "paragraph");
8730        assert_eq!(item_content[1].node_type, "codeBlock");
8731
8732        // The code block text must survive verbatim — no emoji splitting.
8733        let cb_content = item_content[1].content.as_ref().unwrap();
8734        assert_eq!(cb_content[0].node_type, "text");
8735        assert_eq!(
8736            cb_content[0].text.as_deref().unwrap(),
8737            "x = Foo::Bar::Baz.new"
8738        );
8739
8740        // And there should be no emoji node anywhere in the tree.
8741        assert!(
8742            item_content
8743                .iter()
8744                .flat_map(|n| n.content.iter().flat_map(|c| c.iter()))
8745                .all(|n| n.node_type != "emoji"),
8746            "no emoji nodes should be present, got: {item_content:?}"
8747        );
8748    }
8749
8750    #[test]
8751    fn list_item_hardbreak_then_nested_list_still_works() {
8752        // Ensure the hardBreak continuation fix doesn't break nested list
8753        // handling — an indented `- item` line after a hardBreak is still a
8754        // nested list, not a code fence.
8755        let md = "- first\\\n  continuation text\\\n  - nested item\n";
8756        let doc = markdown_to_adf(md).unwrap();
8757        let list = &doc.content[0];
8758        assert_eq!(list.node_type, "bulletList");
8759        let item = &list.content.as_ref().unwrap()[0];
8760        // First paragraph should contain the continuation text joined via hardBreaks
8761        let item_content = item.content.as_ref().unwrap();
8762        let para = &item_content[0];
8763        assert_eq!(para.node_type, "paragraph");
8764        let para_nodes = para.content.as_ref().unwrap();
8765        assert!(
8766            para_nodes
8767                .iter()
8768                .any(|n| n.text.as_deref() == Some("continuation text")),
8769            "continuation text should survive: {para_nodes:?}"
8770        );
8771    }
8772
8773    #[test]
8774    fn list_item_hardbreak_then_image_still_works() {
8775        // Regression check for issue #490: the image-skip behaviour in
8776        // collect_hardbreak_continuations must still hold after the code-fence
8777        // fix.  The `![](url)` line must remain a block-level mediaSingle, not
8778        // be swallowed into the paragraph.
8779        let md = "- first\\\n  ![](https://example.com/x.png){type=file id=x}\n";
8780        let doc = markdown_to_adf(md).unwrap();
8781        let list = &doc.content[0];
8782        let item = &list.content.as_ref().unwrap()[0];
8783        let item_content = item.content.as_ref().unwrap();
8784        // The image should be a sibling block, not part of the first paragraph.
8785        assert!(
8786            item_content.iter().any(|n| n.node_type == "mediaSingle"),
8787            "mediaSingle should still be a block-level sibling, got: {item_content:?}"
8788        );
8789    }
8790
8791    #[test]
8792    fn list_item_hardbreak_then_container_directive_round_trips() {
8793        // Issue #552: Same hardBreak-swallows-block-siblings bug class — a
8794        // container directive (`:::panel`) after a hardBreak must also not be
8795        // consumed as a continuation line.
8796        let adf_json = r#"{"version":1,"type":"doc","content":[
8797          {"type":"bulletList","content":[
8798            {"type":"listItem","content":[
8799              {"type":"paragraph","content":[
8800                {"type":"text","text":"intro"},
8801                {"type":"hardBreak"}
8802              ]},
8803              {"type":"panel","attrs":{"panelType":"info"},"content":[
8804                {"type":"paragraph","content":[
8805                  {"type":"text","text":"panel body"}
8806                ]}
8807              ]}
8808            ]}
8809          ]}
8810        ]}"#;
8811        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8812        let md = adf_to_markdown(&doc).unwrap();
8813        let round_tripped = markdown_to_adf(&md).unwrap();
8814
8815        let item = &round_tripped.content[0].content.as_ref().unwrap()[0];
8816        let item_content = item.content.as_ref().unwrap();
8817        assert!(
8818            item_content.iter().any(|n| n.node_type == "panel"),
8819            "panel should survive as block-level sibling, got: {item_content:?}"
8820        );
8821    }
8822
8823    #[test]
8824    fn inline_code_with_unicode_shortcode_pattern_round_trips() {
8825        // Issue #552: Inline code containing non-ASCII colon-delimited words
8826        // must round-trip without emoji corruption.
8827        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8828          {"type":"text","text":"See "},
8829          {"type":"text","text":"ZBC::配置::Production","marks":[{"type":"code"}]},
8830          {"type":"text","text":" for prod"}
8831        ]}]}"#;
8832        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8833
8834        let md = adf_to_markdown(&doc).unwrap();
8835        let round_tripped = markdown_to_adf(&md).unwrap();
8836        let content = round_tripped.content[0].content.as_ref().unwrap();
8837
8838        assert_eq!(content.len(), 3, "should have 3 nodes: {content:?}");
8839        assert_eq!(content[1].text.as_deref(), Some("ZBC::配置::Production"));
8840        assert!(content[1]
8841            .marks
8842            .as_ref()
8843            .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
8844    }
8845
8846    #[test]
8847    fn code_block_followed_by_shortcode_text_round_trips() {
8848        // Issue #552: A code block with colon-delimited content followed by a
8849        // paragraph containing emoji-like text should not confuse parsing.
8850        let adf_json = r#"{"version":1,"type":"doc","content":[
8851          {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8852            {"type":"text","text":"Foo::Bar::Baz"}
8853          ]},
8854          {"type":"paragraph","content":[
8855            {"type":"text","text":"Status::Active::Running"}
8856          ]}
8857        ]}"#;
8858        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8859
8860        let md = adf_to_markdown(&doc).unwrap();
8861        let round_tripped = markdown_to_adf(&md).unwrap();
8862
8863        assert_eq!(round_tripped.content.len(), 2);
8864        let cb = &round_tripped.content[0];
8865        assert_eq!(cb.node_type, "codeBlock");
8866        let cb_content = cb.content.as_ref().unwrap();
8867        assert_eq!(cb_content[0].text.as_deref(), Some("Foo::Bar::Baz"));
8868
8869        let para = &round_tripped.content[1];
8870        assert_eq!(para.node_type, "paragraph");
8871        let para_content = para.content.as_ref().unwrap();
8872        assert_eq!(para_content.len(), 1);
8873        assert_eq!(para_content[0].node_type, "text");
8874        assert_eq!(
8875            para_content[0].text.as_deref(),
8876            Some("Status::Active::Running")
8877        );
8878    }
8879
8880    #[test]
8881    fn adf_inline_card_to_markdown() {
8882        let doc = AdfDocument {
8883            version: 1,
8884            doc_type: "doc".to_string(),
8885            content: vec![AdfNode::paragraph(vec![AdfNode {
8886                node_type: "inlineCard".to_string(),
8887                attrs: Some(
8888                    serde_json::json!({"url": "https://org.atlassian.net/browse/ACCS-4382"}),
8889                ),
8890                content: None,
8891                text: None,
8892                marks: None,
8893                local_id: None,
8894                parameters: None,
8895            }])],
8896        };
8897        let md = adf_to_markdown(&doc).unwrap();
8898        assert!(md.contains(":card[https://org.atlassian.net/browse/ACCS-4382]"));
8899        assert!(!md.contains("<!-- unsupported inline"));
8900    }
8901
8902    #[test]
8903    fn inline_card_directive_round_trips() {
8904        // inlineCard → :card[url] → inlineCard
8905        let original = AdfDocument {
8906            version: 1,
8907            doc_type: "doc".to_string(),
8908            content: vec![AdfNode::paragraph(vec![AdfNode::inline_card(
8909                "https://org.atlassian.net/browse/ACCS-4382",
8910            )])],
8911        };
8912        let md = adf_to_markdown(&original).unwrap();
8913        assert!(md.contains(":card[https://org.atlassian.net/browse/ACCS-4382]"));
8914        let restored = markdown_to_adf(&md).unwrap();
8915        let node = &restored.content[0].content.as_ref().unwrap()[0];
8916        assert_eq!(node.node_type, "inlineCard");
8917        assert_eq!(
8918            node.attrs.as_ref().unwrap()["url"],
8919            "https://org.atlassian.net/browse/ACCS-4382"
8920        );
8921    }
8922
8923    #[test]
8924    fn inline_card_directive_parsed_from_jfm() {
8925        // :card[url] in JFM → inlineCard in ADF
8926        let doc = markdown_to_adf("See :card[https://example.com/issue/123] for details.").unwrap();
8927        let nodes = doc.content[0].content.as_ref().unwrap();
8928        assert_eq!(nodes[0].node_type, "text");
8929        assert_eq!(nodes[0].text.as_deref(), Some("See "));
8930        assert_eq!(nodes[1].node_type, "inlineCard");
8931        assert_eq!(
8932            nodes[1].attrs.as_ref().unwrap()["url"],
8933            "https://example.com/issue/123"
8934        );
8935        assert_eq!(nodes[2].node_type, "text");
8936        assert_eq!(nodes[2].text.as_deref(), Some(" for details."));
8937    }
8938
8939    #[test]
8940    fn self_link_becomes_link_mark_not_inline_card() {
8941        // Issue #378: [url](url) should produce a link mark, not inlineCard.
8942        // inlineCard is only for :card[url] directives and bare URLs.
8943        let doc = markdown_to_adf("[https://example.com](https://example.com)").unwrap();
8944        let node = &doc.content[0].content.as_ref().unwrap()[0];
8945        assert_eq!(node.node_type, "text");
8946        assert_eq!(node.text.as_deref(), Some("https://example.com"));
8947        let mark = &node.marks.as_ref().unwrap()[0];
8948        assert_eq!(mark.mark_type, "link");
8949        assert_eq!(mark.attrs.as_ref().unwrap()["href"], "https://example.com");
8950    }
8951
8952    #[test]
8953    fn url_link_text_with_trailing_slash_mismatch_becomes_link_mark() {
8954        // Issue #523: [url](url/) where text and href differ only by trailing
8955        // slash should produce a text node with link mark, not an inlineCard.
8956        let doc =
8957            markdown_to_adf("[https://octopz.example.com](https://octopz.example.com/)").unwrap();
8958        let node = &doc.content[0].content.as_ref().unwrap()[0];
8959        assert_eq!(node.node_type, "text");
8960        assert_eq!(node.text.as_deref(), Some("https://octopz.example.com"));
8961        let mark = &node.marks.as_ref().unwrap()[0];
8962        assert_eq!(mark.mark_type, "link");
8963        assert_eq!(
8964            mark.attrs.as_ref().unwrap()["href"],
8965            "https://octopz.example.com/"
8966        );
8967    }
8968
8969    #[test]
8970    fn named_link_does_not_become_inline_card() {
8971        // [#4668](url) — text differs from url, stays as a link mark
8972        let doc = markdown_to_adf("[#4668](https://github.com/org/repo/pull/4668)").unwrap();
8973        let node = &doc.content[0].content.as_ref().unwrap()[0];
8974        assert_eq!(node.node_type, "text");
8975        assert_eq!(node.text.as_deref(), Some("#4668"));
8976        let mark = &node.marks.as_ref().unwrap()[0];
8977        assert_eq!(mark.mark_type, "link");
8978    }
8979
8980    #[test]
8981    fn adf_inline_card_no_url_to_markdown() {
8982        let doc = AdfDocument {
8983            version: 1,
8984            doc_type: "doc".to_string(),
8985            content: vec![AdfNode::paragraph(vec![AdfNode {
8986                node_type: "inlineCard".to_string(),
8987                attrs: Some(serde_json::json!({})),
8988                content: None,
8989                text: None,
8990                marks: None,
8991                local_id: None,
8992                parameters: None,
8993            }])],
8994        };
8995        let md = adf_to_markdown(&doc).unwrap();
8996        // No url attr — renders nothing (not a comment)
8997        assert!(!md.contains("<!-- unsupported inline"));
8998    }
8999
9000    #[test]
9001    fn adf_code_block_no_language_to_markdown() {
9002        let doc = AdfDocument {
9003            version: 1,
9004            doc_type: "doc".to_string(),
9005            content: vec![AdfNode::code_block(None, "plain code")],
9006        };
9007        let md = adf_to_markdown(&doc).unwrap();
9008        assert!(md.contains("```\n"));
9009        assert!(md.contains("plain code"));
9010    }
9011
9012    #[test]
9013    fn adf_code_block_empty_language_to_markdown() {
9014        let doc = AdfDocument {
9015            version: 1,
9016            doc_type: "doc".to_string(),
9017            content: vec![AdfNode::code_block(Some(""), "plain code")],
9018        };
9019        let md = adf_to_markdown(&doc).unwrap();
9020        assert!(md.contains("```\"\"\n"));
9021        assert!(md.contains("plain code"));
9022    }
9023
9024    // ── Additional round-trip tests ────────────────────────────────────
9025
9026    #[test]
9027    fn round_trip_table() {
9028        let md = "| A | B |\n| --- | --- |\n| 1 | 2 |\n";
9029        let adf = markdown_to_adf(md).unwrap();
9030        let restored = adf_to_markdown(&adf).unwrap();
9031        assert!(restored.contains("| A | B |"));
9032        assert!(restored.contains("| 1 | 2 |"));
9033    }
9034
9035    #[test]
9036    fn round_trip_blockquote() {
9037        let md = "> This is quoted\n";
9038        let adf = markdown_to_adf(md).unwrap();
9039        let restored = adf_to_markdown(&adf).unwrap();
9040        assert!(restored.contains("> This is quoted"));
9041    }
9042
9043    #[test]
9044    fn round_trip_image() {
9045        let md = "![Logo](https://example.com/logo.png)\n";
9046        let adf = markdown_to_adf(md).unwrap();
9047        let restored = adf_to_markdown(&adf).unwrap();
9048        assert!(restored.contains("![Logo](https://example.com/logo.png)"));
9049    }
9050
9051    #[test]
9052    fn round_trip_ordered_list() {
9053        let md = "1. A\n2. B\n3. C\n";
9054        let adf = markdown_to_adf(md).unwrap();
9055        let restored = adf_to_markdown(&adf).unwrap();
9056        assert!(restored.contains("1. A"));
9057        assert!(restored.contains("2. B"));
9058        assert!(restored.contains("3. C"));
9059    }
9060
9061    #[test]
9062    fn round_trip_inline_marks() {
9063        let md = "Text with `code` and ~~strike~~ and [link](https://x.com).\n";
9064        let adf = markdown_to_adf(md).unwrap();
9065        let restored = adf_to_markdown(&adf).unwrap();
9066        assert!(restored.contains("`code`"));
9067        assert!(restored.contains("~~strike~~"));
9068        assert!(restored.contains("[link](https://x.com)"));
9069    }
9070
9071    // ── Container directive tests (Tier 2) ───────────────────────────
9072
9073    #[test]
9074    fn panel_info() {
9075        let md = ":::panel{type=info}\nThis is informational.\n:::";
9076        let doc = markdown_to_adf(md).unwrap();
9077        assert_eq!(doc.content[0].node_type, "panel");
9078        assert_eq!(doc.content[0].attrs.as_ref().unwrap()["panelType"], "info");
9079        let inner = doc.content[0].content.as_ref().unwrap();
9080        assert_eq!(inner[0].node_type, "paragraph");
9081    }
9082
9083    #[test]
9084    fn adf_panel_to_markdown() {
9085        let doc = AdfDocument {
9086            version: 1,
9087            doc_type: "doc".to_string(),
9088            content: vec![AdfNode::panel(
9089                "warning",
9090                vec![AdfNode::paragraph(vec![AdfNode::text("Be careful.")])],
9091            )],
9092        };
9093        let md = adf_to_markdown(&doc).unwrap();
9094        assert!(md.contains(":::panel{type=warning}"));
9095        assert!(md.contains("Be careful."));
9096        assert!(md.contains(":::"));
9097    }
9098
9099    #[test]
9100    fn round_trip_panel() {
9101        let md = ":::panel{type=info}\nThis is informational.\n:::\n";
9102        let doc = markdown_to_adf(md).unwrap();
9103        let result = adf_to_markdown(&doc).unwrap();
9104        assert!(result.contains(":::panel{type=info}"));
9105        assert!(result.contains("This is informational."));
9106    }
9107
9108    #[test]
9109    fn expand_with_title() {
9110        let md = ":::expand{title=\"Click me\"}\nHidden content.\n:::";
9111        let doc = markdown_to_adf(md).unwrap();
9112        assert_eq!(doc.content[0].node_type, "expand");
9113        assert_eq!(doc.content[0].attrs.as_ref().unwrap()["title"], "Click me");
9114    }
9115
9116    #[test]
9117    fn adf_expand_to_markdown() {
9118        let doc = AdfDocument {
9119            version: 1,
9120            doc_type: "doc".to_string(),
9121            content: vec![AdfNode::expand(
9122                Some("Details"),
9123                vec![AdfNode::paragraph(vec![AdfNode::text("Inner.")])],
9124            )],
9125        };
9126        let md = adf_to_markdown(&doc).unwrap();
9127        assert!(md.contains(":::expand{title=\"Details\"}"));
9128        assert!(md.contains("Inner."));
9129    }
9130
9131    #[test]
9132    fn round_trip_expand() {
9133        let md = ":::expand{title=\"Details\"}\nInner content.\n:::\n";
9134        let doc = markdown_to_adf(md).unwrap();
9135        let result = adf_to_markdown(&doc).unwrap();
9136        assert!(result.contains(":::expand{title=\"Details\"}"));
9137        assert!(result.contains("Inner content."));
9138    }
9139
9140    #[test]
9141    fn layout_two_columns() {
9142        let md =
9143            "::::layout\n:::column{width=50}\nLeft.\n:::\n:::column{width=50}\nRight.\n:::\n::::";
9144        let doc = markdown_to_adf(md).unwrap();
9145        assert_eq!(doc.content[0].node_type, "layoutSection");
9146        let columns = doc.content[0].content.as_ref().unwrap();
9147        assert_eq!(columns.len(), 2);
9148        assert_eq!(columns[0].node_type, "layoutColumn");
9149        assert_eq!(columns[1].node_type, "layoutColumn");
9150    }
9151
9152    #[test]
9153    fn adf_layout_to_markdown() {
9154        let doc = AdfDocument {
9155            version: 1,
9156            doc_type: "doc".to_string(),
9157            content: vec![AdfNode::layout_section(vec![
9158                AdfNode::layout_column(50, vec![AdfNode::paragraph(vec![AdfNode::text("Left.")])]),
9159                AdfNode::layout_column(50, vec![AdfNode::paragraph(vec![AdfNode::text("Right.")])]),
9160            ])],
9161        };
9162        let md = adf_to_markdown(&doc).unwrap();
9163        assert!(md.contains("::::layout"));
9164        assert!(md.contains(":::column{width=50}"));
9165        assert!(md.contains("Left."));
9166        assert!(md.contains("Right."));
9167    }
9168
9169    #[test]
9170    fn layout_column_localid_roundtrip() {
9171        let adf_json = r#"{
9172            "version": 1,
9173            "type": "doc",
9174            "content": [{
9175                "type": "layoutSection",
9176                "content": [
9177                    {
9178                        "type": "layoutColumn",
9179                        "attrs": {"width": 50.0, "localId": "aabb112233cc"},
9180                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Left"}]}]
9181                    },
9182                    {
9183                        "type": "layoutColumn",
9184                        "attrs": {"width": 50.0, "localId": "ddeeff445566"},
9185                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Right"}]}]
9186                    }
9187                ]
9188            }]
9189        }"#;
9190        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9191        let md = adf_to_markdown(&doc).unwrap();
9192        assert!(
9193            md.contains("localId=aabb112233cc"),
9194            "first column localId should appear in markdown: {md}"
9195        );
9196        assert!(
9197            md.contains("localId=ddeeff445566"),
9198            "second column localId should appear in markdown: {md}"
9199        );
9200        let rt = markdown_to_adf(&md).unwrap();
9201        let cols = rt.content[0].content.as_ref().unwrap();
9202        assert_eq!(
9203            cols[0].attrs.as_ref().unwrap()["localId"],
9204            "aabb112233cc",
9205            "first column localId should round-trip"
9206        );
9207        assert_eq!(
9208            cols[1].attrs.as_ref().unwrap()["localId"],
9209            "ddeeff445566",
9210            "second column localId should round-trip"
9211        );
9212    }
9213
9214    #[test]
9215    fn layout_column_without_localid() {
9216        let md =
9217            "::::layout\n:::column{width=50}\nLeft.\n:::\n:::column{width=50}\nRight.\n:::\n::::";
9218        let doc = markdown_to_adf(md).unwrap();
9219        let cols = doc.content[0].content.as_ref().unwrap();
9220        assert!(
9221            cols[0].attrs.as_ref().unwrap().get("localId").is_none(),
9222            "column without localId should not gain one"
9223        );
9224        let md2 = adf_to_markdown(&doc).unwrap();
9225        assert!(
9226            !md2.contains("localId"),
9227            "no localId should appear in output: {md2}"
9228        );
9229    }
9230
9231    #[test]
9232    fn layout_column_localid_stripped_when_option_set() {
9233        let adf_json = r#"{
9234            "version": 1,
9235            "type": "doc",
9236            "content": [{
9237                "type": "layoutSection",
9238                "content": [{
9239                    "type": "layoutColumn",
9240                    "attrs": {"width": 50.0, "localId": "aabb112233cc"},
9241                    "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Col"}]}]
9242                }]
9243            }]
9244        }"#;
9245        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9246        let opts = RenderOptions {
9247            strip_local_ids: true,
9248            ..Default::default()
9249        };
9250        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
9251        assert!(!md.contains("localId"), "localId should be stripped: {md}");
9252    }
9253
9254    #[test]
9255    fn layout_column_localid_flush_previous() {
9256        // Columns open without explicit `:::` close → flush-previous path
9257        let md = "::::layout\n:::column{width=50 localId=aabb112233cc}\nLeft.\n:::column{width=50 localId=ddeeff445566}\nRight.\n:::\n::::";
9258        let doc = markdown_to_adf(md).unwrap();
9259        let cols = doc.content[0].content.as_ref().unwrap();
9260        assert_eq!(
9261            cols[0].attrs.as_ref().unwrap()["localId"],
9262            "aabb112233cc",
9263            "flush-previous column should preserve localId"
9264        );
9265        assert_eq!(
9266            cols[1].attrs.as_ref().unwrap()["localId"],
9267            "ddeeff445566",
9268            "second column localId should be preserved"
9269        );
9270    }
9271
9272    #[test]
9273    fn layout_column_localid_flush_last() {
9274        // Layout with no closing fence → column never explicitly closed → flush-last path
9275        let md = "::::layout\n:::column{width=50 localId=aabb112233cc}\nOnly column.";
9276        let doc = markdown_to_adf(md).unwrap();
9277        let cols = doc.content[0].content.as_ref().unwrap();
9278        assert_eq!(
9279            cols[0].attrs.as_ref().unwrap()["localId"],
9280            "aabb112233cc",
9281            "flush-last column should preserve localId"
9282        );
9283    }
9284
9285    /// Issue #555: `layoutColumn` fractional `width` must round-trip byte-for-byte.
9286    #[test]
9287    fn issue_555_layout_column_fractional_width_roundtrip() {
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": 66.66},
9297                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Wide"}]}]
9298                    },
9299                    {
9300                        "type": "layoutColumn",
9301                        "attrs": {"width": 33.34},
9302                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Narrow"}]}]
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!(md.contains("width=66.66"), "fractional width in md: {md}");
9310        assert!(md.contains("width=33.34"), "fractional width in md: {md}");
9311        let rt = markdown_to_adf(&md).unwrap();
9312        let cols = rt.content[0].content.as_ref().unwrap();
9313        assert_eq!(cols[0].attrs.as_ref().unwrap()["width"], 66.66);
9314        assert_eq!(cols[1].attrs.as_ref().unwrap()["width"], 33.34);
9315    }
9316
9317    /// Issue #555: `layoutColumn` 5/6 widths (`83.33`) round-trip without precision loss.
9318    #[test]
9319    fn issue_555_layout_column_five_sixths_width_roundtrip() {
9320        let adf_json = r#"{
9321            "version": 1,
9322            "type": "doc",
9323            "content": [{
9324                "type": "layoutSection",
9325                "content": [
9326                    {
9327                        "type": "layoutColumn",
9328                        "attrs": {"width": 83.33},
9329                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Wide"}]}]
9330                    },
9331                    {
9332                        "type": "layoutColumn",
9333                        "attrs": {"width": 16.67},
9334                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Narrow"}]}]
9335                    }
9336                ]
9337            }]
9338        }"#;
9339        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9340        let md = adf_to_markdown(&doc).unwrap();
9341        let rt = markdown_to_adf(&md).unwrap();
9342        let cols = rt.content[0].content.as_ref().unwrap();
9343        assert_eq!(cols[0].attrs.as_ref().unwrap()["width"], 83.33);
9344        assert_eq!(cols[1].attrs.as_ref().unwrap()["width"], 16.67);
9345    }
9346
9347    /// Issue #555: `layoutColumn` integer widths must NOT be coerced to floats on round-trip.
9348    #[test]
9349    fn issue_555_layout_column_integer_width_preserved() {
9350        let adf_json = r#"{
9351            "version": 1,
9352            "type": "doc",
9353            "content": [{
9354                "type": "layoutSection",
9355                "content": [
9356                    {
9357                        "type": "layoutColumn",
9358                        "attrs": {"width": 50},
9359                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "A"}]}]
9360                    },
9361                    {
9362                        "type": "layoutColumn",
9363                        "attrs": {"width": 50},
9364                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "B"}]}]
9365                    }
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=50") && !md.contains("width=50."),
9373            "integer width should render without decimal: {md}"
9374        );
9375        let rt = markdown_to_adf(&md).unwrap();
9376        let cols = rt.content[0].content.as_ref().unwrap();
9377        let w0 = &cols[0].attrs.as_ref().unwrap()["width"];
9378        assert!(
9379            w0.is_i64() || w0.is_u64(),
9380            "width should remain a JSON integer, got: {w0}"
9381        );
9382        assert_eq!(w0.as_i64(), Some(50));
9383    }
9384
9385    /// Issue #555: `mediaSingle` integer `width` must NOT be coerced to a float on round-trip.
9386    #[test]
9387    fn issue_555_media_single_integer_width_preserved() {
9388        let adf_json = r#"{
9389            "version": 1,
9390            "type": "doc",
9391            "content": [{
9392                "type": "mediaSingle",
9393                "attrs": {"layout": "center", "width": 800},
9394                "content": [
9395                    {"type": "media", "attrs": {"type": "external", "url": "https://example.com/image.png"}}
9396                ]
9397            }]
9398        }"#;
9399        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9400        let md = adf_to_markdown(&doc).unwrap();
9401        assert!(
9402            md.contains("width=800") && !md.contains("width=800."),
9403            "integer width should render without decimal: {md}"
9404        );
9405        let rt = markdown_to_adf(&md).unwrap();
9406        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9407        let w = &ms_attrs["width"];
9408        assert!(
9409            w.is_i64() || w.is_u64(),
9410            "mediaSingle width should remain JSON integer, got: {w}"
9411        );
9412        assert_eq!(w.as_i64(), Some(800));
9413    }
9414
9415    /// Issue #555 (follow-up): fractional `mediaSingle` width (e.g. `66.5`, a
9416    /// percentage-based size common in Jira layouts) must survive `from-adf`
9417    /// instead of being silently dropped.
9418    #[test]
9419    fn issue_555_media_single_fractional_width_preserved() {
9420        let adf_json = r#"{
9421            "version": 1,
9422            "type": "doc",
9423            "content": [{
9424                "type": "mediaSingle",
9425                "attrs": {"layout": "center", "width": 66.5},
9426                "content": [
9427                    {"type": "media", "attrs": {"type": "external", "url": "https://example.com/diagram.png"}}
9428                ]
9429            }]
9430        }"#;
9431        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9432        let md = adf_to_markdown(&doc).unwrap();
9433        assert!(
9434            md.contains("width=66.5"),
9435            "fractional width must appear in JFM: {md}"
9436        );
9437        let rt = markdown_to_adf(&md).unwrap();
9438        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9439        assert_eq!(ms_attrs["width"], 66.5);
9440    }
9441
9442    /// Issue #555: `mediaSingle` float `width` must not be dropped during ADF→JFM→ADF.
9443    #[test]
9444    fn issue_555_media_single_float_width_preserved() {
9445        let adf_json = r#"{
9446            "version": 1,
9447            "type": "doc",
9448            "content": [{
9449                "type": "mediaSingle",
9450                "attrs": {"layout": "center", "width": 800.0},
9451                "content": [
9452                    {"type": "media", "attrs": {"type": "external", "url": "https://example.com/image.png"}}
9453                ]
9454            }]
9455        }"#;
9456        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9457        let md = adf_to_markdown(&doc).unwrap();
9458        assert!(
9459            md.contains("width=800.0"),
9460            "float width should render with decimal: {md}"
9461        );
9462        let rt = markdown_to_adf(&md).unwrap();
9463        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9464        let w = &ms_attrs["width"];
9465        assert!(
9466            w.is_f64(),
9467            "mediaSingle float width should stay a JSON float, got: {w}"
9468        );
9469        assert_eq!(w.as_f64(), Some(800.0));
9470    }
9471
9472    /// Issue #555: `mediaSingle` with `layout=wide` and integer width must round-trip.
9473    #[test]
9474    fn issue_555_media_single_wide_layout_integer_width_roundtrip() {
9475        let adf_json = r#"{
9476            "version": 1,
9477            "type": "doc",
9478            "content": [{
9479                "type": "mediaSingle",
9480                "attrs": {"layout": "wide", "width": 1420},
9481                "content": [
9482                    {"type": "media", "attrs": {"type": "external", "url": "https://ex.com/x.png"}}
9483                ]
9484            }]
9485        }"#;
9486        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9487        let md = adf_to_markdown(&doc).unwrap();
9488        let rt = markdown_to_adf(&md).unwrap();
9489        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9490        assert_eq!(ms_attrs["layout"], "wide");
9491        let w = &ms_attrs["width"];
9492        assert!(
9493            w.is_i64() || w.is_u64(),
9494            "mediaSingle width should remain JSON integer, got: {w}"
9495        );
9496        assert_eq!(w.as_i64(), Some(1420));
9497    }
9498
9499    /// Issue #555: Confluence file-attachment `mediaSingle` with integer `mediaWidth`
9500    /// must round-trip without float coercion.
9501    #[test]
9502    fn issue_555_file_media_single_integer_width_preserved() {
9503        let adf_json = r#"{
9504            "version": 1,
9505            "type": "doc",
9506            "content": [{
9507                "type": "mediaSingle",
9508                "attrs": {"layout": "wide", "width": 1420},
9509                "content": [
9510                    {"type": "media", "attrs": {"id": "abc-123", "type": "file", "collection": "col-1", "width": 1200, "height": 800}}
9511                ]
9512            }]
9513        }"#;
9514        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9515        let md = adf_to_markdown(&doc).unwrap();
9516        let rt = markdown_to_adf(&md).unwrap();
9517        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9518        let ms_w = &ms_attrs["width"];
9519        assert!(ms_w.is_i64() || ms_w.is_u64(), "ms width: {ms_w}");
9520        assert_eq!(ms_w.as_i64(), Some(1420));
9521        let media = &rt.content[0].content.as_ref().unwrap()[0];
9522        let media_attrs = media.attrs.as_ref().unwrap();
9523        let mw = &media_attrs["width"];
9524        assert!(mw.is_i64() || mw.is_u64(), "media width: {mw}");
9525        assert_eq!(mw.as_i64(), Some(1200));
9526        let mh = &media_attrs["height"];
9527        assert!(mh.is_i64() || mh.is_u64(), "media height: {mh}");
9528        assert_eq!(mh.as_i64(), Some(800));
9529    }
9530
9531    /// Issue #555: `fmt_numeric_attr` preserves the original integer/float JSON type.
9532    #[test]
9533    fn issue_555_fmt_numeric_attr_preserves_type() {
9534        assert_eq!(
9535            fmt_numeric_attr(&serde_json::json!(50)).as_deref(),
9536            Some("50")
9537        );
9538        assert_eq!(
9539            fmt_numeric_attr(&serde_json::json!(50.0)).as_deref(),
9540            Some("50.0")
9541        );
9542        assert_eq!(
9543            fmt_numeric_attr(&serde_json::json!(66.66)).as_deref(),
9544            Some("66.66")
9545        );
9546        assert_eq!(
9547            fmt_numeric_attr(&serde_json::json!(-5)).as_deref(),
9548            Some("-5")
9549        );
9550        assert_eq!(fmt_numeric_attr(&serde_json::json!("not a number")), None);
9551        // u64 values above i64::MAX exercise the u64-only branch.
9552        let big = serde_json::Value::Number(serde_json::Number::from(u64::MAX));
9553        assert_eq!(
9554            fmt_numeric_attr(&big).as_deref(),
9555            Some("18446744073709551615")
9556        );
9557        // Null is not a number.
9558        assert_eq!(fmt_numeric_attr(&serde_json::Value::Null), None);
9559    }
9560
9561    /// Issue #555: `parse_numeric_attr` distinguishes integer vs float strings.
9562    #[test]
9563    fn issue_555_parse_numeric_attr_detects_type() {
9564        let v = parse_numeric_attr("800").unwrap();
9565        assert!(v.is_i64() || v.is_u64(), "'800' should parse as integer");
9566        assert_eq!(v.as_i64(), Some(800));
9567
9568        let v = parse_numeric_attr("800.0").unwrap();
9569        assert!(v.is_f64(), "'800.0' should parse as float");
9570        assert_eq!(v.as_f64(), Some(800.0));
9571
9572        let v = parse_numeric_attr("66.66").unwrap();
9573        assert!(v.is_f64());
9574        assert_eq!(v.as_f64(), Some(66.66));
9575
9576        // Scientific notation is treated as float (matches JSON semantics).
9577        let v = parse_numeric_attr("1e2").unwrap();
9578        assert!(v.is_f64());
9579        assert_eq!(v.as_f64(), Some(100.0));
9580        let v = parse_numeric_attr("1E2").unwrap();
9581        assert!(v.is_f64());
9582        assert_eq!(v.as_f64(), Some(100.0));
9583
9584        // Negative integer.
9585        let v = parse_numeric_attr("-42").unwrap();
9586        assert!(v.is_i64());
9587        assert_eq!(v.as_i64(), Some(-42));
9588
9589        assert!(parse_numeric_attr("not-a-number").is_none());
9590        assert!(parse_numeric_attr("").is_none());
9591        assert!(parse_numeric_attr("1.2.3").is_none());
9592    }
9593
9594    /// Issue #555: fractional `mediaSingle` width with non-default `layout=wide`
9595    /// must preserve both the layout and the fractional width through round-trip.
9596    #[test]
9597    fn issue_555_media_single_wide_layout_fractional_width_roundtrip() {
9598        let adf_json = r#"{
9599            "version": 1,
9600            "type": "doc",
9601            "content": [{
9602                "type": "mediaSingle",
9603                "attrs": {"layout": "wide", "width": 83.33},
9604                "content": [
9605                    {"type": "media", "attrs": {"type": "external", "url": "https://ex.com/x.png"}}
9606                ]
9607            }]
9608        }"#;
9609        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9610        let md = adf_to_markdown(&doc).unwrap();
9611        assert!(md.contains("layout=wide"), "layout must appear in md: {md}");
9612        assert!(md.contains("width=83.33"), "width must appear in md: {md}");
9613        let rt = markdown_to_adf(&md).unwrap();
9614        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9615        assert_eq!(ms_attrs["layout"], "wide");
9616        assert_eq!(ms_attrs["width"], 83.33);
9617    }
9618
9619    /// Issue #555: fractional `mediaWidth` on a Confluence file-attachment
9620    /// `mediaSingle` must round-trip (exercises the file-branch `mediaWidth`
9621    /// render path, which previously used `as_u64` and silently dropped floats).
9622    #[test]
9623    fn issue_555_file_media_single_fractional_media_width_preserved() {
9624        let adf_json = r#"{
9625            "version": 1,
9626            "type": "doc",
9627            "content": [{
9628                "type": "mediaSingle",
9629                "attrs": {"layout": "wide", "width": 66.5},
9630                "content": [
9631                    {"type": "media", "attrs": {"id": "abc", "type": "file", "collection": "c"}}
9632                ]
9633            }]
9634        }"#;
9635        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9636        let md = adf_to_markdown(&doc).unwrap();
9637        assert!(md.contains("mediaWidth=66.5"), "mediaWidth in md: {md}");
9638        let rt = markdown_to_adf(&md).unwrap();
9639        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9640        assert_eq!(ms_attrs["width"], 66.5);
9641    }
9642
9643    /// Issue #555: fractional inner `media` width/height on a file attachment
9644    /// must round-trip (exercises the file-branch inner `width`/`height` render
9645    /// path, which previously used `as_u64` and silently dropped floats).
9646    #[test]
9647    fn issue_555_file_media_fractional_inner_dimensions_preserved() {
9648        let adf_json = r#"{
9649            "version": 1,
9650            "type": "doc",
9651            "content": [{
9652                "type": "mediaSingle",
9653                "attrs": {"layout": "center"},
9654                "content": [
9655                    {"type": "media", "attrs": {"id": "abc", "type": "file", "collection": "c", "width": 1200.5, "height": 800.25}}
9656                ]
9657            }]
9658        }"#;
9659        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9660        let md = adf_to_markdown(&doc).unwrap();
9661        assert!(md.contains("width=1200.5"), "width in md: {md}");
9662        assert!(md.contains("height=800.25"), "height in md: {md}");
9663        let rt = markdown_to_adf(&md).unwrap();
9664        let media = &rt.content[0].content.as_ref().unwrap()[0];
9665        let attrs = media.attrs.as_ref().unwrap();
9666        assert_eq!(attrs["width"], 1200.5);
9667        assert_eq!(attrs["height"], 800.25);
9668    }
9669
9670    #[test]
9671    fn decisions_list() {
9672        let md = ":::decisions\n- <> Use PostgreSQL\n- <> REST API\n:::";
9673        let doc = markdown_to_adf(md).unwrap();
9674        assert_eq!(doc.content[0].node_type, "decisionList");
9675        let items = doc.content[0].content.as_ref().unwrap();
9676        assert_eq!(items.len(), 2);
9677        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DECIDED");
9678    }
9679
9680    // decisionItem is inline-only per ADF schema — its content must be
9681    // text/inline nodes, not a paragraph wrapper (issue #753).
9682    #[test]
9683    fn decision_item_content_is_inline_not_paragraph() {
9684        let md = ":::decisions\n- <> Use Rust\n:::";
9685        let doc = markdown_to_adf(md).unwrap();
9686        let items = doc.content[0].content.as_ref().unwrap();
9687        let first_child = &items[0].content.as_ref().unwrap()[0];
9688        assert_eq!(
9689            first_child.node_type, "text",
9690            "decisionItem must contain inline nodes directly, not a paragraph wrapper"
9691        );
9692        assert_eq!(first_child.text.as_deref(), Some("Use Rust"));
9693    }
9694
9695    #[test]
9696    fn adf_decisions_to_markdown() {
9697        let doc = AdfDocument {
9698            version: 1,
9699            doc_type: "doc".to_string(),
9700            content: vec![AdfNode::decision_list(vec![AdfNode::decision_item(
9701                "DECIDED",
9702                vec![AdfNode::paragraph(vec![AdfNode::text("Use PostgreSQL")])],
9703            )])],
9704        };
9705        let md = adf_to_markdown(&doc).unwrap();
9706        assert!(md.contains(":::decisions"));
9707        assert!(md.contains("- <> Use PostgreSQL"));
9708    }
9709
9710    #[test]
9711    fn bodied_extension_container() {
9712        let md = ":::extension{type=com.forge key=my-macro}\nContent.\n:::";
9713        let doc = markdown_to_adf(md).unwrap();
9714        assert_eq!(doc.content[0].node_type, "bodiedExtension");
9715        assert_eq!(
9716            doc.content[0].attrs.as_ref().unwrap()["extensionType"],
9717            "com.forge"
9718        );
9719    }
9720
9721    #[test]
9722    fn adf_bodied_extension_to_markdown() {
9723        let doc = AdfDocument {
9724            version: 1,
9725            doc_type: "doc".to_string(),
9726            content: vec![AdfNode::bodied_extension(
9727                "com.forge",
9728                "my-macro",
9729                vec![AdfNode::paragraph(vec![AdfNode::text("Content.")])],
9730            )],
9731        };
9732        let md = adf_to_markdown(&doc).unwrap();
9733        assert!(md.contains(":::extension{type=com.forge key=my-macro}"));
9734        assert!(md.contains("Content."));
9735    }
9736
9737    // ── Leaf block directive tests (Tier 3) ──────────────────────────
9738
9739    #[test]
9740    fn leaf_block_card() {
9741        let doc = markdown_to_adf("::card[https://example.com/browse/PROJ-123]").unwrap();
9742        assert_eq!(doc.content[0].node_type, "blockCard");
9743        assert_eq!(
9744            doc.content[0].attrs.as_ref().unwrap()["url"],
9745            "https://example.com/browse/PROJ-123"
9746        );
9747    }
9748
9749    #[test]
9750    fn adf_block_card_to_markdown() {
9751        let doc = AdfDocument {
9752            version: 1,
9753            doc_type: "doc".to_string(),
9754            content: vec![AdfNode::block_card("https://example.com/browse/PROJ-123")],
9755        };
9756        let md = adf_to_markdown(&doc).unwrap();
9757        assert!(md.contains("::card[https://example.com/browse/PROJ-123]"));
9758    }
9759
9760    #[test]
9761    fn round_trip_block_card() {
9762        let md = "::card[https://example.com/browse/PROJ-123]\n";
9763        let doc = markdown_to_adf(md).unwrap();
9764        let result = adf_to_markdown(&doc).unwrap();
9765        assert!(result.contains("::card[https://example.com/browse/PROJ-123]"));
9766    }
9767
9768    #[test]
9769    fn leaf_embed_card() {
9770        let doc =
9771            markdown_to_adf("::embed[https://figma.com/file/abc]{layout=wide width=80}").unwrap();
9772        assert_eq!(doc.content[0].node_type, "embedCard");
9773        let attrs = doc.content[0].attrs.as_ref().unwrap();
9774        assert_eq!(attrs["url"], "https://figma.com/file/abc");
9775        assert_eq!(attrs["layout"], "wide");
9776        assert_eq!(attrs["width"], 80.0);
9777    }
9778
9779    #[test]
9780    fn leaf_embed_card_with_original_height() {
9781        let doc = markdown_to_adf(
9782            "::embed[https://example.com]{layout=center originalHeight=732 width=100}",
9783        )
9784        .unwrap();
9785        assert_eq!(doc.content[0].node_type, "embedCard");
9786        let attrs = doc.content[0].attrs.as_ref().unwrap();
9787        assert_eq!(attrs["url"], "https://example.com");
9788        assert_eq!(attrs["layout"], "center");
9789        assert_eq!(attrs["originalHeight"], 732.0);
9790        assert_eq!(attrs["width"], 100.0);
9791    }
9792
9793    #[test]
9794    fn adf_embed_card_to_markdown() {
9795        let doc = AdfDocument {
9796            version: 1,
9797            doc_type: "doc".to_string(),
9798            content: vec![AdfNode::embed_card(
9799                "https://figma.com/file/abc",
9800                Some("wide"),
9801                None,
9802                Some(80.0),
9803            )],
9804        };
9805        let md = adf_to_markdown(&doc).unwrap();
9806        assert!(md.contains("::embed[https://figma.com/file/abc]{layout=wide width=80}"));
9807    }
9808
9809    #[test]
9810    fn adf_embed_card_original_height_to_markdown() {
9811        let doc = AdfDocument {
9812            version: 1,
9813            doc_type: "doc".to_string(),
9814            content: vec![AdfNode::embed_card(
9815                "https://example.com",
9816                Some("center"),
9817                Some(732.0),
9818                Some(100.0),
9819            )],
9820        };
9821        let md = adf_to_markdown(&doc).unwrap();
9822        assert!(
9823            md.contains("::embed[https://example.com]{layout=center originalHeight=732 width=100}"),
9824            "expected originalHeight and width in md: {md}"
9825        );
9826    }
9827
9828    #[test]
9829    fn embed_card_roundtrip_with_all_attrs() {
9830        let adf_json = r#"{"version":1,"type":"doc","content":[{
9831            "type":"embedCard",
9832            "attrs":{"layout":"center","originalHeight":732.0,"url":"https://example.com","width":100.0}
9833        }]}"#;
9834        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9835        let md = adf_to_markdown(&doc).unwrap();
9836        assert!(
9837            md.contains("originalHeight=732"),
9838            "originalHeight missing from md: {md}"
9839        );
9840        assert!(md.contains("width=100"), "width missing from md: {md}");
9841        let rt = markdown_to_adf(&md).unwrap();
9842        let attrs = rt.content[0].attrs.as_ref().unwrap();
9843        assert_eq!(attrs["originalHeight"], 732.0);
9844        assert_eq!(attrs["width"], 100.0);
9845        assert_eq!(attrs["layout"], "center");
9846        assert_eq!(attrs["url"], "https://example.com");
9847    }
9848
9849    #[test]
9850    fn embed_card_fractional_dimensions() {
9851        let doc = AdfDocument {
9852            version: 1,
9853            doc_type: "doc".to_string(),
9854            content: vec![AdfNode::embed_card(
9855                "https://example.com",
9856                Some("center"),
9857                Some(732.5),
9858                Some(99.9),
9859            )],
9860        };
9861        let md = adf_to_markdown(&doc).unwrap();
9862        assert!(
9863            md.contains("originalHeight=732.5"),
9864            "fractional originalHeight missing: {md}"
9865        );
9866        assert!(md.contains("width=99.9"), "fractional width missing: {md}");
9867        let rt = markdown_to_adf(&md).unwrap();
9868        let attrs = rt.content[0].attrs.as_ref().unwrap();
9869        assert_eq!(attrs["originalHeight"], 732.5);
9870        assert_eq!(attrs["width"], 99.9);
9871    }
9872
9873    #[test]
9874    fn embed_card_integer_width_in_json() {
9875        // JSON integer (not float) should also be extracted via as_f64()
9876        let adf_json = r#"{"version":1,"type":"doc","content":[{
9877            "type":"embedCard",
9878            "attrs":{"url":"https://example.com","width":100}
9879        }]}"#;
9880        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9881        let md = adf_to_markdown(&doc).unwrap();
9882        assert!(
9883            md.contains("width=100"),
9884            "integer width missing from md: {md}"
9885        );
9886        let rt = markdown_to_adf(&md).unwrap();
9887        assert_eq!(rt.content[0].attrs.as_ref().unwrap()["width"], 100.0);
9888    }
9889
9890    #[test]
9891    fn embed_card_only_original_height() {
9892        // originalHeight without width
9893        let adf_json = r#"{"version":1,"type":"doc","content":[{
9894            "type":"embedCard",
9895            "attrs":{"url":"https://example.com","originalHeight":500.0}
9896        }]}"#;
9897        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9898        let md = adf_to_markdown(&doc).unwrap();
9899        assert!(
9900            md.contains("originalHeight=500"),
9901            "originalHeight missing: {md}"
9902        );
9903        assert!(!md.contains("width="), "width should not appear: {md}");
9904        let rt = markdown_to_adf(&md).unwrap();
9905        let attrs = rt.content[0].attrs.as_ref().unwrap();
9906        assert_eq!(attrs["originalHeight"], 500.0);
9907        assert!(attrs.get("width").is_none());
9908    }
9909
9910    #[test]
9911    fn leaf_void_extension() {
9912        let doc = markdown_to_adf("::extension{type=com.atlassian.macro key=jira-chart}").unwrap();
9913        assert_eq!(doc.content[0].node_type, "extension");
9914        assert_eq!(
9915            doc.content[0].attrs.as_ref().unwrap()["extensionType"],
9916            "com.atlassian.macro"
9917        );
9918        assert_eq!(
9919            doc.content[0].attrs.as_ref().unwrap()["extensionKey"],
9920            "jira-chart"
9921        );
9922    }
9923
9924    #[test]
9925    fn adf_void_extension_to_markdown() {
9926        let doc = AdfDocument {
9927            version: 1,
9928            doc_type: "doc".to_string(),
9929            content: vec![AdfNode::extension(
9930                "com.atlassian.macro",
9931                "jira-chart",
9932                None,
9933            )],
9934        };
9935        let md = adf_to_markdown(&doc).unwrap();
9936        assert!(md.contains("::extension{type=com.atlassian.macro key=jira-chart}"));
9937    }
9938
9939    // ── Bare URL autolink tests ──────────────────────────────────────
9940
9941    #[test]
9942    fn bare_url_autolink() {
9943        let doc = markdown_to_adf("Visit https://example.com today").unwrap();
9944        let content = doc.content[0].content.as_ref().unwrap();
9945        assert_eq!(content[0].text.as_deref(), Some("Visit "));
9946        assert_eq!(content[1].node_type, "inlineCard");
9947        assert_eq!(
9948            content[1].attrs.as_ref().unwrap()["url"],
9949            "https://example.com"
9950        );
9951        assert_eq!(content[2].text.as_deref(), Some(" today"));
9952    }
9953
9954    #[test]
9955    fn bare_url_strips_trailing_punctuation() {
9956        let doc = markdown_to_adf("See https://example.com.").unwrap();
9957        let content = doc.content[0].content.as_ref().unwrap();
9958        assert_eq!(
9959            content[1].attrs.as_ref().unwrap()["url"],
9960            "https://example.com"
9961        );
9962    }
9963
9964    #[test]
9965    fn bare_url_round_trip() {
9966        let doc = markdown_to_adf("Visit https://example.com/path today").unwrap();
9967        let md = adf_to_markdown(&doc).unwrap();
9968        assert!(md.contains(":card[https://example.com/path]"));
9969    }
9970
9971    // ── Issue #475: plain-text URL must not become inlineCard ─────────
9972
9973    #[test]
9974    fn plain_text_url_round_trips_as_text() {
9975        // A text node whose content is a bare URL (no link mark) must
9976        // survive ADF→JFM→ADF as a text node, not an inlineCard.
9977        let adf_json = r#"{
9978            "version": 1,
9979            "type": "doc",
9980            "content": [{
9981                "type": "paragraph",
9982                "content": [
9983                    {"type": "text", "text": "https://example.com/some/path/to/resource"}
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!(
9994            content[0].text.as_deref(),
9995            Some("https://example.com/some/path/to/resource")
9996        );
9997    }
9998
9999    #[test]
10000    fn url_text_with_link_mark_round_trips_as_text_node() {
10001        // Issue #523: A text node whose content is a URL with a link mark
10002        // (href differs by trailing slash) must round-trip as text+link,
10003        // not become an inlineCard.
10004        let adf_json = r#"{
10005            "version": 1,
10006            "type": "doc",
10007            "content": [{
10008                "type": "paragraph",
10009                "content": [{
10010                    "type": "text",
10011                    "text": "https://octopz.example.com",
10012                    "marks": [{"type": "link", "attrs": {"href": "https://octopz.example.com/"}}]
10013                }]
10014            }]
10015        }"#;
10016        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10017        let jfm = adf_to_markdown(&adf).unwrap();
10018        let roundtripped = markdown_to_adf(&jfm).unwrap();
10019        let content = roundtripped.content[0].content.as_ref().unwrap();
10020        assert_eq!(content.len(), 1, "should be a single node");
10021        assert_eq!(content[0].node_type, "text", "must be text, not inlineCard");
10022        assert_eq!(
10023            content[0].text.as_deref(),
10024            Some("https://octopz.example.com")
10025        );
10026        let mark = &content[0].marks.as_ref().unwrap()[0];
10027        assert_eq!(mark.mark_type, "link");
10028        assert_eq!(
10029            mark.attrs.as_ref().unwrap()["href"],
10030            "https://octopz.example.com/"
10031        );
10032    }
10033
10034    #[test]
10035    fn url_text_with_exact_link_mark_round_trips() {
10036        // Variant: text and href are identical (no trailing slash difference).
10037        let adf_json = r#"{
10038            "version": 1,
10039            "type": "doc",
10040            "content": [{
10041                "type": "paragraph",
10042                "content": [{
10043                    "type": "text",
10044                    "text": "https://example.com/path",
10045                    "marks": [{"type": "link", "attrs": {"href": "https://example.com/path"}}]
10046                }]
10047            }]
10048        }"#;
10049        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10050        let jfm = adf_to_markdown(&adf).unwrap();
10051        let roundtripped = markdown_to_adf(&jfm).unwrap();
10052        let content = roundtripped.content[0].content.as_ref().unwrap();
10053        assert_eq!(content.len(), 1, "should be a single node");
10054        assert_eq!(content[0].node_type, "text");
10055        assert_eq!(content[0].text.as_deref(), Some("https://example.com/path"));
10056        let mark = &content[0].marks.as_ref().unwrap()[0];
10057        assert_eq!(mark.mark_type, "link");
10058    }
10059
10060    #[test]
10061    fn plain_text_url_amid_text_round_trips() {
10062        // URL embedded in surrounding text, without link mark.
10063        let adf_json = r#"{
10064            "version": 1,
10065            "type": "doc",
10066            "content": [{
10067                "type": "paragraph",
10068                "content": [
10069                    {"type": "text", "text": "see https://example.com for info"}
10070                ]
10071            }]
10072        }"#;
10073        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10074        let jfm = adf_to_markdown(&adf).unwrap();
10075        let roundtripped = markdown_to_adf(&jfm).unwrap();
10076        let content = roundtripped.content[0].content.as_ref().unwrap();
10077        assert_eq!(content.len(), 1);
10078        assert_eq!(content[0].node_type, "text");
10079        assert_eq!(
10080            content[0].text.as_deref(),
10081            Some("see https://example.com for info")
10082        );
10083    }
10084
10085    #[test]
10086    fn plain_text_multiple_urls_round_trips() {
10087        let adf_json = r#"{
10088            "version": 1,
10089            "type": "doc",
10090            "content": [{
10091                "type": "paragraph",
10092                "content": [
10093                    {"type": "text", "text": "http://a.com and https://b.com"}
10094                ]
10095            }]
10096        }"#;
10097        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10098        let jfm = adf_to_markdown(&adf).unwrap();
10099        let roundtripped = markdown_to_adf(&jfm).unwrap();
10100        let content = roundtripped.content[0].content.as_ref().unwrap();
10101        assert_eq!(content.len(), 1);
10102        assert_eq!(content[0].node_type, "text");
10103        assert_eq!(
10104            content[0].text.as_deref(),
10105            Some("http://a.com and https://b.com")
10106        );
10107    }
10108
10109    #[test]
10110    fn plain_text_http_prefix_no_url_unchanged() {
10111        // "http" without "://" should not be escaped or altered.
10112        let adf_json = r#"{
10113            "version": 1,
10114            "type": "doc",
10115            "content": [{
10116                "type": "paragraph",
10117                "content": [
10118                    {"type": "text", "text": "the http header is important"}
10119                ]
10120            }]
10121        }"#;
10122        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10123        let jfm = adf_to_markdown(&adf).unwrap();
10124        let roundtripped = markdown_to_adf(&jfm).unwrap();
10125        let content = roundtripped.content[0].content.as_ref().unwrap();
10126        assert_eq!(
10127            content[0].text.as_deref(),
10128            Some("the http header is important")
10129        );
10130    }
10131
10132    #[test]
10133    fn linked_url_text_round_trips() {
10134        // A text node that is exactly a URL with a link mark pointing to the
10135        // same URL must round-trip as a single text node with a link mark
10136        // (no inlineCard, no lost/split content).
10137        let adf_json = r#"{
10138            "version": 1,
10139            "type": "doc",
10140            "content": [{
10141                "type": "paragraph",
10142                "content": [{
10143                    "type": "text",
10144                    "text": "https://example.com",
10145                    "marks": [{"type": "link", "attrs": {"href": "https://example.com"}}]
10146                }]
10147            }]
10148        }"#;
10149        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10150        let jfm = adf_to_markdown(&adf).unwrap();
10151        let roundtripped = markdown_to_adf(&jfm).unwrap();
10152        let content = roundtripped.content[0].content.as_ref().unwrap();
10153        assert_eq!(content.len(), 1);
10154        assert_eq!(content[0].node_type, "text");
10155        assert_eq!(content[0].text.as_deref(), Some("https://example.com"));
10156        let mark = &content[0].marks.as_ref().unwrap()[0];
10157        assert_eq!(mark.mark_type, "link");
10158        assert_eq!(mark.attrs.as_ref().unwrap()["href"], "https://example.com");
10159    }
10160
10161    // ── Issue #493: bracket-link ambiguity ─────────────────────────────
10162
10163    #[test]
10164    fn escape_link_brackets_unit() {
10165        assert_eq!(escape_link_brackets("hello"), "hello");
10166        assert_eq!(escape_link_brackets("["), "\\[");
10167        assert_eq!(escape_link_brackets("]"), "\\]");
10168        assert_eq!(escape_link_brackets("[PROJ-456]"), "\\[PROJ-456\\]");
10169        assert_eq!(escape_link_brackets("a[b]c"), "a\\[b\\]c");
10170    }
10171
10172    #[test]
10173    fn bracket_text_with_link_mark_escapes_brackets() {
10174        // A text node whose content is "[" with a link mark should escape
10175        // the bracket so it does not create ambiguous markdown link syntax.
10176        let doc = AdfDocument {
10177            version: 1,
10178            doc_type: "doc".to_string(),
10179            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10180                "[",
10181                vec![AdfMark::link("https://example.com")],
10182            )])],
10183        };
10184        let md = adf_to_markdown(&doc).unwrap();
10185        assert_eq!(md.trim(), "[\\[](https://example.com)");
10186    }
10187
10188    #[test]
10189    fn bracket_text_with_link_mark_round_trips() {
10190        // Issue #493 reproducer: adjacent text nodes sharing a link mark
10191        // where the first node's content is "[".
10192        let adf_json = r#"{
10193            "type": "doc",
10194            "version": 1,
10195            "content": [{
10196                "type": "paragraph",
10197                "content": [
10198                    {
10199                        "type": "text",
10200                        "text": "[",
10201                        "marks": [{"type": "link", "attrs": {"href": "https://example.com/ticket/123"}}]
10202                    },
10203                    {
10204                        "type": "text",
10205                        "text": "PROJ-456] Fix the auth bug",
10206                        "marks": [
10207                            {"type": "underline"},
10208                            {"type": "link", "attrs": {"href": "https://example.com/ticket/123"}}
10209                        ]
10210                    }
10211                ]
10212            }]
10213        }"#;
10214        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10215        let jfm = adf_to_markdown(&adf).unwrap();
10216
10217        // The markdown should contain escaped brackets inside the link
10218        assert!(jfm.contains("\\["), "opening bracket should be escaped");
10219
10220        // Round-trip: both text nodes must survive with link marks
10221        let rt = markdown_to_adf(&jfm).unwrap();
10222        let content = rt.content[0].content.as_ref().unwrap();
10223
10224        // All text nodes that were part of the link must still carry a link mark
10225        let link_nodes: Vec<_> = content
10226            .iter()
10227            .filter(|n| {
10228                n.marks
10229                    .as_ref()
10230                    .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "link"))
10231            })
10232            .collect();
10233        assert!(
10234            !link_nodes.is_empty(),
10235            "link mark must be preserved on round-trip"
10236        );
10237
10238        // The combined text across all nodes should contain the original content
10239        let all_text: String = content.iter().filter_map(|n| n.text.as_deref()).collect();
10240        assert!(
10241            all_text.contains('['),
10242            "literal '[' must survive round-trip"
10243        );
10244        assert!(
10245            all_text.contains("PROJ-456]"),
10246            "continuation text must survive round-trip"
10247        );
10248    }
10249
10250    #[test]
10251    fn closing_bracket_in_link_text_round_trips() {
10252        // A text node containing "]" inside a link should be escaped and
10253        // survive round-trip without breaking the link syntax.
10254        let doc = AdfDocument {
10255            version: 1,
10256            doc_type: "doc".to_string(),
10257            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10258                "item]",
10259                vec![AdfMark::link("https://example.com")],
10260            )])],
10261        };
10262        let md = adf_to_markdown(&doc).unwrap();
10263        assert_eq!(md.trim(), "[item\\]](https://example.com)");
10264
10265        let rt = markdown_to_adf(&md).unwrap();
10266        let content = rt.content[0].content.as_ref().unwrap();
10267        assert_eq!(content[0].text.as_deref(), Some("item]"));
10268        assert!(content[0]
10269            .marks
10270            .as_ref()
10271            .unwrap()
10272            .iter()
10273            .any(|m| m.mark_type == "link"));
10274    }
10275
10276    #[test]
10277    fn brackets_in_link_text_round_trip() {
10278        // Text containing both [ and ] inside a link should round-trip.
10279        let doc = AdfDocument {
10280            version: 1,
10281            doc_type: "doc".to_string(),
10282            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10283                "[PROJ-123]",
10284                vec![AdfMark::link("https://example.com")],
10285            )])],
10286        };
10287        let md = adf_to_markdown(&doc).unwrap();
10288        assert_eq!(md.trim(), "[\\[PROJ-123\\]](https://example.com)");
10289
10290        let rt = markdown_to_adf(&md).unwrap();
10291        let content = rt.content[0].content.as_ref().unwrap();
10292        assert_eq!(content[0].text.as_deref(), Some("[PROJ-123]"));
10293        assert!(content[0]
10294            .marks
10295            .as_ref()
10296            .unwrap()
10297            .iter()
10298            .any(|m| m.mark_type == "link"));
10299    }
10300
10301    #[test]
10302    fn plain_text_brackets_not_escaped() {
10303        // Brackets in plain text (no link mark) must NOT be escaped.
10304        let doc = AdfDocument {
10305            version: 1,
10306            doc_type: "doc".to_string(),
10307            content: vec![AdfNode::paragraph(vec![AdfNode::text(
10308                "see [PROJ-123] for details",
10309            )])],
10310        };
10311        let md = adf_to_markdown(&doc).unwrap();
10312        assert_eq!(md.trim(), "see [PROJ-123] for details");
10313    }
10314
10315    #[test]
10316    fn link_with_no_brackets_unchanged() {
10317        // A normal link with no brackets in the text should be unaffected.
10318        let doc = AdfDocument {
10319            version: 1,
10320            doc_type: "doc".to_string(),
10321            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10322                "click here",
10323                vec![AdfMark::link("https://example.com")],
10324            )])],
10325        };
10326        let md = adf_to_markdown(&doc).unwrap();
10327        assert_eq!(md.trim(), "[click here](https://example.com)");
10328    }
10329
10330    // ── Issue #551: URL brackets in link-marked text round-trip ────────
10331
10332    #[test]
10333    fn url_with_brackets_as_link_text_round_trips() {
10334        // Issue #551: a text node whose content is a URL containing square
10335        // brackets and which carries a link mark must round-trip verbatim.
10336        // Previously the URL-as-link-text fast path preserved `\[` and `\]`
10337        // escapes in the emitted text, corrupting the text content.
10338        let href = "https://example.com/dashboard?filter[0]=active&filter[1]=pending";
10339        let doc = AdfDocument {
10340            version: 1,
10341            doc_type: "doc".to_string(),
10342            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10343                href,
10344                vec![AdfMark::link(href)],
10345            )])],
10346        };
10347        let md = adf_to_markdown(&doc).unwrap();
10348        let rt = markdown_to_adf(&md).unwrap();
10349        let content = rt.content[0].content.as_ref().unwrap();
10350        assert_eq!(content.len(), 1);
10351        assert_eq!(content[0].node_type, "text");
10352        assert_eq!(content[0].text.as_deref(), Some(href));
10353        let mark = &content[0].marks.as_ref().unwrap()[0];
10354        assert_eq!(mark.mark_type, "link");
10355        assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10356    }
10357
10358    #[test]
10359    fn url_with_brackets_embedded_in_link_text_round_trips() {
10360        // Issue #551 updated reproducer: a link-marked text node containing
10361        // both prose and an embedded URL with brackets must round-trip
10362        // without the embedded URL being detected as a bare-URL inlineCard
10363        // or the brackets terminating the link syntax early.  This mirrors
10364        // the comment reproducer which uses an ellipsis character between
10365        // the brackets and a distinct href value.
10366        let href = "https://example.com/logs?query=service%20environment%20data&from=100&to=200";
10367        let text =
10368            "See the logs: https://example.com/logs?query=service[\u{2026}]data&from=100&to=200";
10369        let doc = AdfDocument {
10370            version: 1,
10371            doc_type: "doc".to_string(),
10372            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10373                text,
10374                vec![AdfMark::link(href)],
10375            )])],
10376        };
10377        let md = adf_to_markdown(&doc).unwrap();
10378        let rt = markdown_to_adf(&md).unwrap();
10379        let content = rt.content[0].content.as_ref().unwrap();
10380        assert_eq!(content.len(), 1, "content split unexpectedly: {content:?}");
10381        assert_eq!(content[0].node_type, "text");
10382        assert_eq!(content[0].text.as_deref(), Some(text));
10383        let mark = &content[0].marks.as_ref().unwrap()[0];
10384        assert_eq!(mark.mark_type, "link");
10385        assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10386    }
10387
10388    #[test]
10389    fn url_with_brackets_plain_text_round_trips() {
10390        // Issue #551 original reproducer: plain text with an embedded URL
10391        // that contains square brackets must round-trip verbatim.
10392        let text =
10393            "See the dashboard: https://example.com/dashboard?filter[0]=active&filter[1]=pending";
10394        let doc = AdfDocument {
10395            version: 1,
10396            doc_type: "doc".to_string(),
10397            content: vec![AdfNode::paragraph(vec![AdfNode::text(text)])],
10398        };
10399        let md = adf_to_markdown(&doc).unwrap();
10400        let rt = markdown_to_adf(&md).unwrap();
10401        let content = rt.content[0].content.as_ref().unwrap();
10402        assert_eq!(content.len(), 1);
10403        assert_eq!(content[0].node_type, "text");
10404        assert_eq!(content[0].text.as_deref(), Some(text));
10405        assert!(content[0].marks.is_none());
10406    }
10407
10408    #[test]
10409    fn url_with_link_mark_embedded_no_brackets_round_trips() {
10410        // Regression guard: embedding a bare URL inside link-marked text
10411        // (no brackets) must not create an inlineCard on round-trip.
10412        let href = "https://example.com/";
10413        let text = "See https://example.com/ for more";
10414        let doc = AdfDocument {
10415            version: 1,
10416            doc_type: "doc".to_string(),
10417            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10418                text,
10419                vec![AdfMark::link(href)],
10420            )])],
10421        };
10422        let md = adf_to_markdown(&doc).unwrap();
10423        let rt = markdown_to_adf(&md).unwrap();
10424        let content = rt.content[0].content.as_ref().unwrap();
10425        assert_eq!(content.len(), 1);
10426        assert_eq!(content[0].node_type, "text");
10427        assert_eq!(content[0].text.as_deref(), Some(text));
10428        let mark = &content[0].marks.as_ref().unwrap()[0];
10429        assert_eq!(mark.mark_type, "link");
10430        assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10431    }
10432
10433    #[test]
10434    fn nested_brackets_in_link_text_round_trip() {
10435        // Regression guard: nested brackets in link-marked text must
10436        // round-trip without corrupting the content.
10437        let href = "https://x.com";
10438        let text = "foo [a[b]c] bar";
10439        let doc = AdfDocument {
10440            version: 1,
10441            doc_type: "doc".to_string(),
10442            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10443                text,
10444                vec![AdfMark::link(href)],
10445            )])],
10446        };
10447        let md = adf_to_markdown(&doc).unwrap();
10448        let rt = markdown_to_adf(&md).unwrap();
10449        let content = rt.content[0].content.as_ref().unwrap();
10450        assert_eq!(content.len(), 1);
10451        assert_eq!(content[0].node_type, "text");
10452        assert_eq!(content[0].text.as_deref(), Some(text));
10453    }
10454
10455    #[test]
10456    fn bracket_url_bracket_in_link_text_round_trips() {
10457        // Regression guard: a link-marked text containing brackets on both
10458        // sides of an embedded URL (with brackets of its own) must
10459        // round-trip intact.  This exercises interaction between the
10460        // URL-as-link-text fast path, bare-URL detection, and bracket
10461        // escape handling.
10462        let href = "https://y.com";
10463        let text = "[see https://x.com/a[0]=1 here]";
10464        let doc = AdfDocument {
10465            version: 1,
10466            doc_type: "doc".to_string(),
10467            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10468                text,
10469                vec![AdfMark::link(href)],
10470            )])],
10471        };
10472        let md = adf_to_markdown(&doc).unwrap();
10473        let rt = markdown_to_adf(&md).unwrap();
10474        let content = rt.content[0].content.as_ref().unwrap();
10475        assert_eq!(content.len(), 1);
10476        assert_eq!(content[0].node_type, "text");
10477        assert_eq!(content[0].text.as_deref(), Some(text));
10478        let mark = &content[0].marks.as_ref().unwrap()[0];
10479        assert_eq!(mark.mark_type, "link");
10480        assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10481    }
10482
10483    #[test]
10484    fn escape_bare_urls_applied_inside_link_text() {
10485        // White-box: when a text node carries a link mark, bare URLs in the
10486        // text must still be escaped with `\h` so the parser does not
10487        // auto-link them into an inlineCard inside the link.  Without this,
10488        // round-trip of link-marked prose containing an embedded URL
10489        // silently corrupts on re-parse (issue #551).
10490        let doc = AdfDocument {
10491            version: 1,
10492            doc_type: "doc".to_string(),
10493            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10494                "See https://example.com/",
10495                vec![AdfMark::link("https://target.example.com/")],
10496            )])],
10497        };
10498        let md = adf_to_markdown(&doc).unwrap();
10499        assert!(
10500            md.contains(r"\https://example.com/"),
10501            "bare URL inside link text must be escaped, got: {md}"
10502        );
10503    }
10504
10505    #[test]
10506    fn inline_card_still_round_trips() {
10507        // An actual inlineCard node should still round-trip correctly
10508        // (it uses :card[url] syntax, not bare URL).
10509        let adf_json = r#"{
10510            "version": 1,
10511            "type": "doc",
10512            "content": [{
10513                "type": "paragraph",
10514                "content": [
10515                    {"type": "inlineCard", "attrs": {"url": "https://example.com/page"}}
10516                ]
10517            }]
10518        }"#;
10519        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10520        let jfm = adf_to_markdown(&adf).unwrap();
10521        assert!(jfm.contains(":card[https://example.com/page]"));
10522        let roundtripped = markdown_to_adf(&jfm).unwrap();
10523        let content = roundtripped.content[0].content.as_ref().unwrap();
10524        assert_eq!(content[0].node_type, "inlineCard");
10525        assert_eq!(
10526            content[0].attrs.as_ref().unwrap()["url"],
10527            "https://example.com/page"
10528        );
10529    }
10530
10531    // ── Issue #553: inlineCard round-trip with problematic URLs ───────
10532
10533    #[test]
10534    fn url_safe_in_bracket_content_balanced() {
10535        // Balanced brackets — depth never returns to zero mid-string.
10536        assert!(url_safe_in_bracket_content("https://example.com"));
10537        assert!(url_safe_in_bracket_content("https://example.com/[id]"));
10538        assert!(url_safe_in_bracket_content("a[b[c]d]e"));
10539        assert!(url_safe_in_bracket_content(""));
10540    }
10541
10542    #[test]
10543    fn url_safe_in_bracket_content_unbalanced() {
10544        // A `]` with no prior `[` would close `:card[...]` early.
10545        assert!(!url_safe_in_bracket_content("a]b"));
10546        assert!(!url_safe_in_bracket_content("https://example.com/path]end"));
10547        // Embedded newline breaks inline directive parsing.
10548        assert!(!url_safe_in_bracket_content("a\nb"));
10549    }
10550
10551    #[test]
10552    fn inline_card_url_with_closing_bracket_round_trip() {
10553        // Issue #553 defensive fix: a URL that contains `]` (unbalanced) must
10554        // round-trip without truncation.  The renderer must switch to the
10555        // quoted attribute form `:card[]{url="..."}` so the parser's
10556        // depth-based bracket matcher does not terminate the directive early.
10557        let adf_json = r#"{
10558            "version": 1,
10559            "type": "doc",
10560            "content": [{
10561                "type": "paragraph",
10562                "content": [
10563                    {"type": "text", "text": "See: "},
10564                    {"type": "inlineCard", "attrs": {"url": "https://example.com/path]end/?q=1"}}
10565                ]
10566            }]
10567        }"#;
10568        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10569        let jfm = adf_to_markdown(&adf).unwrap();
10570        assert!(
10571            jfm.contains(r#":card[]{url="https://example.com/path]end/?q=1"}"#),
10572            "expected attr-form for URL with `]`, got: {jfm}"
10573        );
10574        let rt = markdown_to_adf(&jfm).unwrap();
10575        let content = rt.content[0].content.as_ref().unwrap();
10576        assert_eq!(content.len(), 2, "expected 2 inline nodes, got {content:?}");
10577        assert_eq!(content[0].node_type, "text");
10578        assert_eq!(content[0].text.as_deref(), Some("See: "));
10579        assert_eq!(content[1].node_type, "inlineCard");
10580        assert_eq!(
10581            content[1].attrs.as_ref().unwrap()["url"],
10582            "https://example.com/path]end/?q=1"
10583        );
10584    }
10585
10586    #[test]
10587    fn inline_card_url_with_closing_bracket_preserves_local_id() {
10588        // Attr-form `:card[]{url=... localId=...}` must preserve localId too.
10589        let adf_json = r#"{
10590            "version": 1,
10591            "type": "doc",
10592            "content": [{
10593                "type": "paragraph",
10594                "content": [
10595                    {"type": "inlineCard", "attrs": {
10596                        "url": "https://example.com/a]b",
10597                        "localId": "c-77"
10598                    }}
10599                ]
10600            }]
10601        }"#;
10602        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10603        let jfm = adf_to_markdown(&adf).unwrap();
10604        assert!(
10605            jfm.contains(r#"url="https://example.com/a]b""#),
10606            "jfm: {jfm}"
10607        );
10608        assert!(jfm.contains("localId=c-77"), "jfm: {jfm}");
10609        let rt = markdown_to_adf(&jfm).unwrap();
10610        let card = &rt.content[0].content.as_ref().unwrap()[0];
10611        assert_eq!(card.node_type, "inlineCard");
10612        assert_eq!(
10613            card.attrs.as_ref().unwrap()["url"],
10614            "https://example.com/a]b"
10615        );
10616        assert_eq!(card.attrs.as_ref().unwrap()["localId"], "c-77");
10617    }
10618
10619    #[test]
10620    fn block_card_url_with_closing_bracket_round_trip() {
10621        // Same defensive fix applied to the leaf directive `::card`.
10622        let adf_json = r#"{
10623            "version": 1,
10624            "type": "doc",
10625            "content": [
10626                {"type": "blockCard", "attrs": {"url": "https://example.com/path]end"}}
10627            ]
10628        }"#;
10629        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10630        let jfm = adf_to_markdown(&adf).unwrap();
10631        assert!(
10632            jfm.contains(r#"::card[]{url="https://example.com/path]end"}"#),
10633            "expected attr-form for blockCard with `]`, got: {jfm}"
10634        );
10635        let rt = markdown_to_adf(&jfm).unwrap();
10636        assert_eq!(rt.content[0].node_type, "blockCard");
10637        assert_eq!(
10638            rt.content[0].attrs.as_ref().unwrap()["url"],
10639            "https://example.com/path]end"
10640        );
10641    }
10642
10643    #[test]
10644    fn block_card_attr_form_parses_without_renderer() {
10645        // Directly parsing `::card[]{url="..."}` exercises the attr-URL
10646        // fallback in the leaf-directive dispatcher (covers the `url` lookup
10647        // path independently of the ADF→JFM renderer).
10648        let doc = markdown_to_adf(r#"::card[]{url="https://example.com/a"}"#).unwrap();
10649        assert_eq!(doc.content[0].node_type, "blockCard");
10650        assert_eq!(
10651            doc.content[0].attrs.as_ref().unwrap()["url"],
10652            "https://example.com/a"
10653        );
10654    }
10655
10656    #[test]
10657    fn block_card_attr_form_url_overrides_content() {
10658        // When both bracket-content and `url=` attribute are present on
10659        // `::card`, the attribute wins.  Mirrors the inline-directive
10660        // behaviour and keeps hand-edited JFM forgiving.
10661        let doc =
10662            markdown_to_adf(r#"::card[https://old.example.com]{url="https://new.example.com"}"#)
10663                .unwrap();
10664        assert_eq!(doc.content[0].node_type, "blockCard");
10665        assert_eq!(
10666            doc.content[0].attrs.as_ref().unwrap()["url"],
10667            "https://new.example.com"
10668        );
10669    }
10670
10671    #[test]
10672    fn block_card_attr_form_with_layout_and_width() {
10673        // Attr-URL form combined with layout/width attrs — ensures all
10674        // sibling attrs still pass through after the URL lookup.
10675        let doc =
10676            markdown_to_adf(r#"::card[]{url="https://example.com/a]b" layout=wide width=80}"#)
10677                .unwrap();
10678        let attrs = doc.content[0].attrs.as_ref().unwrap();
10679        assert_eq!(attrs["url"], "https://example.com/a]b");
10680        assert_eq!(attrs["layout"], "wide");
10681        assert_eq!(attrs["width"], 80);
10682    }
10683
10684    #[test]
10685    fn inline_card_issue_553_reproducer() {
10686        // Verbatim reproducer from issue #553: an inlineCard in a paragraph
10687        // with preceding text must round-trip as an inlineCard, not degrade to
10688        // a text node with a link mark.
10689        let adf_json = r#"{
10690            "version": 1,
10691            "type": "doc",
10692            "content": [{
10693                "type": "paragraph",
10694                "content": [
10695                    {"type": "text", "text": "See the related page: "},
10696                    {"type": "inlineCard", "attrs": {
10697                        "url": "https://example.atlassian.net/wiki/spaces/ENG/pages/12345678"
10698                    }}
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!(content.len(), 2);
10707        assert_eq!(content[0].node_type, "text");
10708        assert_eq!(content[1].node_type, "inlineCard");
10709        assert_eq!(
10710            content[1].attrs.as_ref().unwrap()["url"],
10711            "https://example.atlassian.net/wiki/spaces/ENG/pages/12345678"
10712        );
10713    }
10714
10715    #[test]
10716    fn inline_card_attr_form_parses_even_without_renderer() {
10717        // Directly parsing `:card[]{url="..."}` should yield an inlineCard.
10718        let doc = markdown_to_adf(r#":card[]{url="https://example.com/a"}"#).unwrap();
10719        let node = &doc.content[0].content.as_ref().unwrap()[0];
10720        assert_eq!(node.node_type, "inlineCard");
10721        assert_eq!(node.attrs.as_ref().unwrap()["url"], "https://example.com/a");
10722    }
10723
10724    #[test]
10725    fn inline_card_attr_form_url_overrides_content() {
10726        // When both bracket-content and `url=` attr are present, attr wins.
10727        // This keeps the parser forgiving of hand-edited JFM where a user
10728        // copied an old bracket form but added attrs.
10729        let doc =
10730            markdown_to_adf(r#":card[https://old.example.com]{url="https://new.example.com"}"#)
10731                .unwrap();
10732        let node = &doc.content[0].content.as_ref().unwrap()[0];
10733        assert_eq!(node.node_type, "inlineCard");
10734        assert_eq!(
10735            node.attrs.as_ref().unwrap()["url"],
10736            "https://new.example.com"
10737        );
10738    }
10739
10740    // ── Issue #553 (updated): mark-wrapped URL must not become inlineCard ──
10741
10742    #[test]
10743    fn url_with_link_and_underline_marks_round_trip() {
10744        // Issue #553 (updated reproducer): a `text` node whose content is a
10745        // URL and that carries both `link` and `underline` marks must round-
10746        // trip as text+marks, not be promoted to an `inlineCard`.
10747        let adf_json = r#"{
10748            "version": 1,
10749            "type": "doc",
10750            "content": [{
10751                "type": "paragraph",
10752                "content": [
10753                    {"type": "text", "text": "See results at: "},
10754                    {"type": "text",
10755                     "text": "https://example.com/projects/abc123/analytics",
10756                     "marks": [
10757                        {"type": "link", "attrs": {"href": "https://example.com/projects/abc123/analytics"}},
10758                        {"type": "underline"}
10759                     ]},
10760                    {"type": "text", "text": " for details."}
10761                ]
10762            }]
10763        }"#;
10764        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10765        let jfm = adf_to_markdown(&adf).unwrap();
10766        let rt = markdown_to_adf(&jfm).unwrap();
10767        let content = rt.content[0].content.as_ref().unwrap();
10768        assert_eq!(
10769            content.len(),
10770            3,
10771            "expected 3 inline nodes, got: {content:?}"
10772        );
10773        assert_eq!(content[0].node_type, "text");
10774        assert_eq!(
10775            content[1].node_type, "text",
10776            "must stay text, not inlineCard"
10777        );
10778        assert_eq!(
10779            content[1].text.as_deref(),
10780            Some("https://example.com/projects/abc123/analytics")
10781        );
10782        let mark_types: Vec<&str> = content[1]
10783            .marks
10784            .as_deref()
10785            .unwrap_or(&[])
10786            .iter()
10787            .map(|m| m.mark_type.as_str())
10788            .collect();
10789        assert_eq!(mark_types, vec!["link", "underline"], "marks lost");
10790        assert_eq!(content[2].node_type, "text");
10791    }
10792
10793    #[test]
10794    fn url_inside_bracketed_span_stays_text() {
10795        // `[URL]{underline}` in JFM means "underline this URL text", not
10796        // "create a smart link that's underlined".  The nested parse_inline
10797        // call must not auto-promote the bare URL to an inlineCard.
10798        let doc = markdown_to_adf("[https://example.com]{underline}").unwrap();
10799        let node = &doc.content[0].content.as_ref().unwrap()[0];
10800        assert_eq!(node.node_type, "text");
10801        assert_eq!(node.text.as_deref(), Some("https://example.com"));
10802        let mark_types: Vec<&str> = node
10803            .marks
10804            .as_deref()
10805            .unwrap_or(&[])
10806            .iter()
10807            .map(|m| m.mark_type.as_str())
10808            .collect();
10809        assert_eq!(mark_types, vec!["underline"]);
10810    }
10811
10812    #[test]
10813    fn url_inside_emphasis_stays_text() {
10814        // Bold, italic, and strike-wrapped URLs should remain as text nodes,
10815        // not get promoted to inlineCards by the nested inline parser.
10816        for (md, mark) in [
10817            ("**https://example.com**", "strong"),
10818            ("*https://example.com*", "em"),
10819            ("~~https://example.com~~", "strike"),
10820        ] {
10821            let doc = markdown_to_adf(md).unwrap();
10822            let node = &doc.content[0].content.as_ref().unwrap()[0];
10823            assert_eq!(node.node_type, "text", "md={md}: must be text");
10824            assert_eq!(node.text.as_deref(), Some("https://example.com"));
10825            let mark_types: Vec<&str> = node
10826                .marks
10827                .as_deref()
10828                .unwrap_or(&[])
10829                .iter()
10830                .map(|m| m.mark_type.as_str())
10831                .collect();
10832            assert_eq!(mark_types, vec![mark], "md={md}: wrong marks");
10833        }
10834    }
10835
10836    #[test]
10837    fn url_inside_span_directive_stays_text() {
10838        // `:span[URL]{color=red}` should not promote the URL to an inlineCard.
10839        let doc = markdown_to_adf(":span[https://example.com]{color=red}").unwrap();
10840        let node = &doc.content[0].content.as_ref().unwrap()[0];
10841        assert_eq!(node.node_type, "text");
10842        assert_eq!(node.text.as_deref(), Some("https://example.com"));
10843        let mark = &node.marks.as_ref().unwrap()[0];
10844        assert_eq!(mark.mark_type, "textColor");
10845    }
10846
10847    #[test]
10848    fn url_as_link_text_with_underline_after_link_mark_order() {
10849        // Reverse mark order — underline appears BEFORE link in the ADF array.
10850        // The JFM form is `[[text](url)]{underline}`; the nested parser must
10851        // still keep the URL as plain text.
10852        let adf_json = r#"{
10853            "version": 1,
10854            "type": "doc",
10855            "content": [{
10856                "type": "paragraph",
10857                "content": [
10858                    {"type": "text",
10859                     "text": "https://example.com",
10860                     "marks": [
10861                        {"type": "underline"},
10862                        {"type": "link", "attrs": {"href": "https://example.com"}}
10863                     ]}
10864                ]
10865            }]
10866        }"#;
10867        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10868        let jfm = adf_to_markdown(&adf).unwrap();
10869        let rt = markdown_to_adf(&jfm).unwrap();
10870        let node = &rt.content[0].content.as_ref().unwrap()[0];
10871        assert_eq!(node.node_type, "text", "must stay text, got: {node:?}");
10872        assert_eq!(node.text.as_deref(), Some("https://example.com"));
10873        let mark_types: Vec<&str> = node
10874            .marks
10875            .as_deref()
10876            .unwrap_or(&[])
10877            .iter()
10878            .map(|m| m.mark_type.as_str())
10879            .collect();
10880        assert_eq!(mark_types, vec!["underline", "link"]);
10881    }
10882
10883    #[test]
10884    fn bare_url_at_top_level_still_becomes_inline_card() {
10885        // Regression guard: the suppression only applies inside mark-wrapping
10886        // constructs.  A bare URL in ordinary paragraph text must still be
10887        // detected and promoted to an inlineCard.
10888        let doc = markdown_to_adf("Visit https://example.com today").unwrap();
10889        let content = doc.content[0].content.as_ref().unwrap();
10890        assert_eq!(content.len(), 3);
10891        assert_eq!(content[0].node_type, "text");
10892        assert_eq!(content[1].node_type, "inlineCard");
10893        assert_eq!(
10894            content[1].attrs.as_ref().unwrap()["url"],
10895            "https://example.com"
10896        );
10897        assert_eq!(content[2].node_type, "text");
10898    }
10899
10900    // ── Block-level attribute marks (Tier 5/6) ───────────────────────
10901
10902    #[test]
10903    fn paragraph_align_center() {
10904        let md = "Centered text.\n{align=center}";
10905        let doc = markdown_to_adf(md).unwrap();
10906        let marks = doc.content[0].marks.as_ref().unwrap();
10907        assert_eq!(marks[0].mark_type, "alignment");
10908        assert_eq!(marks[0].attrs.as_ref().unwrap()["align"], "center");
10909    }
10910
10911    #[test]
10912    fn adf_alignment_to_markdown() {
10913        let mut node = AdfNode::paragraph(vec![AdfNode::text("Centered.")]);
10914        node.marks = Some(vec![AdfMark::alignment("center")]);
10915        let doc = AdfDocument {
10916            version: 1,
10917            doc_type: "doc".to_string(),
10918            content: vec![node],
10919        };
10920        let md = adf_to_markdown(&doc).unwrap();
10921        assert!(md.contains("Centered."));
10922        assert!(md.contains("{align=center}"));
10923    }
10924
10925    #[test]
10926    fn round_trip_alignment() {
10927        let md = "Centered.\n{align=center}\n";
10928        let doc = markdown_to_adf(md).unwrap();
10929        let result = adf_to_markdown(&doc).unwrap();
10930        assert!(result.contains("{align=center}"));
10931    }
10932
10933    #[test]
10934    fn paragraph_indent() {
10935        let md = "Indented.\n{indent=2}";
10936        let doc = markdown_to_adf(md).unwrap();
10937        let marks = doc.content[0].marks.as_ref().unwrap();
10938        assert_eq!(marks[0].mark_type, "indentation");
10939        assert_eq!(marks[0].attrs.as_ref().unwrap()["level"], 2);
10940    }
10941
10942    #[test]
10943    fn code_block_breakout() {
10944        let md = "```python\ndef f(): pass\n```\n{breakout=wide}";
10945        let doc = markdown_to_adf(md).unwrap();
10946        let marks = doc.content[0].marks.as_ref().unwrap();
10947        assert_eq!(marks[0].mark_type, "breakout");
10948        assert_eq!(marks[0].attrs.as_ref().unwrap()["mode"], "wide");
10949        assert!(marks[0].attrs.as_ref().unwrap().get("width").is_none());
10950    }
10951
10952    #[test]
10953    fn code_block_breakout_with_width() {
10954        let md = "```python\ndef f(): pass\n```\n{breakout=wide breakoutWidth=1200}";
10955        let doc = markdown_to_adf(md).unwrap();
10956        let marks = doc.content[0].marks.as_ref().unwrap();
10957        assert_eq!(marks[0].mark_type, "breakout");
10958        assert_eq!(marks[0].attrs.as_ref().unwrap()["mode"], "wide");
10959        assert_eq!(marks[0].attrs.as_ref().unwrap()["width"], 1200);
10960    }
10961
10962    #[test]
10963    fn adf_breakout_to_markdown() {
10964        let mut node = AdfNode::code_block(Some("python"), "pass");
10965        node.marks = Some(vec![AdfMark::breakout("wide", None)]);
10966        let doc = AdfDocument {
10967            version: 1,
10968            doc_type: "doc".to_string(),
10969            content: vec![node],
10970        };
10971        let md = adf_to_markdown(&doc).unwrap();
10972        assert!(md.contains("{breakout=wide}"));
10973        assert!(!md.contains("breakoutWidth"));
10974    }
10975
10976    #[test]
10977    fn adf_breakout_with_width_to_markdown() {
10978        let mut node = AdfNode::code_block(Some("python"), "pass");
10979        node.marks = Some(vec![AdfMark::breakout("wide", Some(1200))]);
10980        let doc = AdfDocument {
10981            version: 1,
10982            doc_type: "doc".to_string(),
10983            content: vec![node],
10984        };
10985        let md = adf_to_markdown(&doc).unwrap();
10986        assert!(md.contains("breakout=wide"));
10987        assert!(md.contains("breakoutWidth=1200"));
10988    }
10989
10990    #[test]
10991    fn breakout_width_round_trip() {
10992        let adf_json = r#"{"version":1,"type":"doc","content":[{
10993            "type":"codeBlock",
10994            "attrs":{"language":"text"},
10995            "marks":[{"type":"breakout","attrs":{"mode":"wide","width":1200}}],
10996            "content":[{"type":"text","text":"some code"}]
10997        }]}"#;
10998        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
10999        let md = adf_to_markdown(&doc).unwrap();
11000        assert!(md.contains("breakout=wide"));
11001        assert!(md.contains("breakoutWidth=1200"));
11002        let round_tripped = markdown_to_adf(&md).unwrap();
11003        let marks = round_tripped.content[0].marks.as_ref().unwrap();
11004        let breakout = marks.iter().find(|m| m.mark_type == "breakout").unwrap();
11005        assert_eq!(breakout.attrs.as_ref().unwrap()["mode"], "wide");
11006        assert_eq!(breakout.attrs.as_ref().unwrap()["width"], 1200);
11007    }
11008
11009    // ── Attribute extensions — media & table (Tier 5) ────────────────
11010
11011    #[test]
11012    fn image_with_layout_attrs() {
11013        let doc = markdown_to_adf("![alt](url){layout=wide width=80}").unwrap();
11014        let node = &doc.content[0];
11015        assert_eq!(node.node_type, "mediaSingle");
11016        let attrs = node.attrs.as_ref().unwrap();
11017        assert_eq!(attrs["layout"], "wide");
11018        assert_eq!(attrs["width"], 80);
11019    }
11020
11021    #[test]
11022    fn adf_image_with_layout_to_markdown() {
11023        let mut node = AdfNode::media_single("url", Some("alt"));
11024        node.attrs.as_mut().unwrap()["layout"] = serde_json::json!("wide");
11025        node.attrs.as_mut().unwrap()["width"] = serde_json::json!(80);
11026        let doc = AdfDocument {
11027            version: 1,
11028            doc_type: "doc".to_string(),
11029            content: vec![node],
11030        };
11031        let md = adf_to_markdown(&doc).unwrap();
11032        assert!(md.contains("![alt](url){layout=wide width=80}"));
11033    }
11034
11035    #[test]
11036    fn table_with_layout_attrs() {
11037        let md = "| H |\n| --- |\n| C |\n{layout=wide numbered}";
11038        let doc = markdown_to_adf(md).unwrap();
11039        let table = &doc.content[0];
11040        assert_eq!(table.node_type, "table");
11041        let attrs = table.attrs.as_ref().unwrap();
11042        assert_eq!(attrs["layout"], "wide");
11043        assert_eq!(attrs["isNumberColumnEnabled"], true);
11044    }
11045
11046    #[test]
11047    fn adf_table_with_attrs_to_markdown() {
11048        let mut table = AdfNode::table(vec![
11049            AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
11050                AdfNode::text("H"),
11051            ])])]),
11052            AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
11053                AdfNode::text("C"),
11054            ])])]),
11055        ]);
11056        table.attrs = Some(serde_json::json!({"layout": "wide", "isNumberColumnEnabled": true}));
11057        let doc = AdfDocument {
11058            version: 1,
11059            doc_type: "doc".to_string(),
11060            content: vec![table],
11061        };
11062        let md = adf_to_markdown(&doc).unwrap();
11063        assert!(md.contains("{layout=wide numbered}"));
11064    }
11065
11066    // ── Attribute extensions — inline marks (Tier 5) ─────────────────
11067
11068    #[test]
11069    fn underline_bracketed_span() {
11070        let doc = markdown_to_adf("This is [underlined text]{underline} here.").unwrap();
11071        let content = doc.content[0].content.as_ref().unwrap();
11072        assert_eq!(content[1].text.as_deref(), Some("underlined text"));
11073        let marks = content[1].marks.as_ref().unwrap();
11074        assert_eq!(marks[0].mark_type, "underline");
11075    }
11076
11077    #[test]
11078    fn adf_underline_to_markdown() {
11079        let doc = AdfDocument {
11080            version: 1,
11081            doc_type: "doc".to_string(),
11082            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11083                "underlined",
11084                vec![AdfMark::underline()],
11085            )])],
11086        };
11087        let md = adf_to_markdown(&doc).unwrap();
11088        assert!(md.contains("[underlined]{underline}"));
11089    }
11090
11091    #[test]
11092    fn round_trip_underline() {
11093        let md = "This is [underlined text]{underline} here.\n";
11094        let doc = markdown_to_adf(md).unwrap();
11095        let result = adf_to_markdown(&doc).unwrap();
11096        assert!(result.contains("[underlined text]{underline}"));
11097    }
11098
11099    #[test]
11100    fn mark_ordering_underline_strong_preserved() {
11101        // Issue #383: mark ordering was non-deterministic
11102        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11103          {"type":"text","text":"bold and underlined","marks":[{"type":"underline"},{"type":"strong"}]}
11104        ]}]}"#;
11105        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11106        let md = adf_to_markdown(&doc).unwrap();
11107        let round_tripped = markdown_to_adf(&md).unwrap();
11108        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11109        let mark_types: Vec<&str> = node
11110            .marks
11111            .as_ref()
11112            .unwrap()
11113            .iter()
11114            .map(|m| m.mark_type.as_str())
11115            .collect();
11116        assert_eq!(
11117            mark_types,
11118            vec!["underline", "strong"],
11119            "mark order should be preserved, got: {mark_types:?}"
11120        );
11121    }
11122
11123    #[test]
11124    fn mark_ordering_link_strong_preserved() {
11125        // Issue #403: link+strong mark order was swapped on round-trip
11126        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11127          {"type":"text","text":"bold link","marks":[
11128            {"type":"link","attrs":{"href":"https://example.com"}},
11129            {"type":"strong"}
11130          ]}
11131        ]}]}"#;
11132        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11133        let md = adf_to_markdown(&doc).unwrap();
11134        let round_tripped = markdown_to_adf(&md).unwrap();
11135        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11136        let mark_types: Vec<&str> = node
11137            .marks
11138            .as_ref()
11139            .unwrap()
11140            .iter()
11141            .map(|m| m.mark_type.as_str())
11142            .collect();
11143        assert_eq!(
11144            mark_types,
11145            vec!["link", "strong"],
11146            "mark order should be preserved, got: {mark_types:?}"
11147        );
11148    }
11149
11150    #[test]
11151    fn mark_ordering_link_textcolor_preserved() {
11152        // Issue #403 comment: link+textColor mark order was swapped on round-trip
11153        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11154          {"type":"text","text":"red link","marks":[
11155            {"type":"link","attrs":{"href":"https://example.com"}},
11156            {"type":"textColor","attrs":{"color":"#ff0000"}}
11157          ]}
11158        ]}]}"##;
11159        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11160        let md = adf_to_markdown(&doc).unwrap();
11161        let round_tripped = markdown_to_adf(&md).unwrap();
11162        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11163        let mark_types: Vec<&str> = node
11164            .marks
11165            .as_ref()
11166            .unwrap()
11167            .iter()
11168            .map(|m| m.mark_type.as_str())
11169            .collect();
11170        assert_eq!(
11171            mark_types,
11172            vec!["link", "textColor"],
11173            "mark order should be preserved, got: {mark_types:?}"
11174        );
11175    }
11176
11177    #[test]
11178    fn mark_ordering_link_em_preserved() {
11179        // Issue #403: link+em mark order should be preserved
11180        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11181          {"type":"text","text":"italic link","marks":[
11182            {"type":"link","attrs":{"href":"https://example.com"}},
11183            {"type":"em"}
11184          ]}
11185        ]}]}"#;
11186        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11187        let md = adf_to_markdown(&doc).unwrap();
11188        let round_tripped = markdown_to_adf(&md).unwrap();
11189        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11190        let mark_types: Vec<&str> = node
11191            .marks
11192            .as_ref()
11193            .unwrap()
11194            .iter()
11195            .map(|m| m.mark_type.as_str())
11196            .collect();
11197        assert_eq!(
11198            mark_types,
11199            vec!["link", "em"],
11200            "mark order should be preserved, got: {mark_types:?}"
11201        );
11202    }
11203
11204    #[test]
11205    fn mark_ordering_link_strike_preserved() {
11206        // Issue #403: link+strike mark order should be preserved
11207        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11208          {"type":"text","text":"struck link","marks":[
11209            {"type":"link","attrs":{"href":"https://example.com"}},
11210            {"type":"strike"}
11211          ]}
11212        ]}]}"#;
11213        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11214        let md = adf_to_markdown(&doc).unwrap();
11215        let round_tripped = markdown_to_adf(&md).unwrap();
11216        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11217        let mark_types: Vec<&str> = node
11218            .marks
11219            .as_ref()
11220            .unwrap()
11221            .iter()
11222            .map(|m| m.mark_type.as_str())
11223            .collect();
11224        assert_eq!(
11225            mark_types,
11226            vec!["link", "strike"],
11227            "mark order should be preserved, got: {mark_types:?}"
11228        );
11229    }
11230
11231    #[test]
11232    fn mark_ordering_strong_link_preserved() {
11233        // Issue #403: [strong, link] order must also be preserved (reverse direction)
11234        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11235          {"type":"text","text":"bold link","marks":[
11236            {"type":"strong"},
11237            {"type":"link","attrs":{"href":"https://example.com"}}
11238          ]}
11239        ]}]}"#;
11240        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11241        let md = adf_to_markdown(&doc).unwrap();
11242        let round_tripped = markdown_to_adf(&md).unwrap();
11243        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11244        let mark_types: Vec<&str> = node
11245            .marks
11246            .as_ref()
11247            .unwrap()
11248            .iter()
11249            .map(|m| m.mark_type.as_str())
11250            .collect();
11251        assert_eq!(
11252            mark_types,
11253            vec!["strong", "link"],
11254            "mark order should be preserved, got: {mark_types:?}"
11255        );
11256    }
11257
11258    #[test]
11259    fn mark_ordering_em_link_preserved() {
11260        // Issue #403: [em, link] order must also be preserved
11261        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11262          {"type":"text","text":"italic link","marks":[
11263            {"type":"em"},
11264            {"type":"link","attrs":{"href":"https://example.com"}}
11265          ]}
11266        ]}]}"#;
11267        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11268        let md = adf_to_markdown(&doc).unwrap();
11269        let round_tripped = markdown_to_adf(&md).unwrap();
11270        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11271        let mark_types: Vec<&str> = node
11272            .marks
11273            .as_ref()
11274            .unwrap()
11275            .iter()
11276            .map(|m| m.mark_type.as_str())
11277            .collect();
11278        assert_eq!(
11279            mark_types,
11280            vec!["em", "link"],
11281            "mark order should be preserved, got: {mark_types:?}"
11282        );
11283    }
11284
11285    #[test]
11286    fn mark_ordering_strike_link_preserved() {
11287        // Issue #403: [strike, link] order must also be preserved
11288        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11289          {"type":"text","text":"struck link","marks":[
11290            {"type":"strike"},
11291            {"type":"link","attrs":{"href":"https://example.com"}}
11292          ]}
11293        ]}]}"#;
11294        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11295        let md = adf_to_markdown(&doc).unwrap();
11296        let round_tripped = markdown_to_adf(&md).unwrap();
11297        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11298        let mark_types: Vec<&str> = node
11299            .marks
11300            .as_ref()
11301            .unwrap()
11302            .iter()
11303            .map(|m| m.mark_type.as_str())
11304            .collect();
11305        assert_eq!(
11306            mark_types,
11307            vec!["strike", "link"],
11308            "mark order should be preserved, got: {mark_types:?}"
11309        );
11310    }
11311
11312    #[test]
11313    fn mark_ordering_underline_link_preserved() {
11314        // Issue #403: [underline, link] order must be preserved
11315        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11316          {"type":"text","text":"click here","marks":[
11317            {"type":"underline"},
11318            {"type":"link","attrs":{"href":"https://example.com"}}
11319          ]}
11320        ]}]}"#;
11321        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11322        let md = adf_to_markdown(&doc).unwrap();
11323        let round_tripped = markdown_to_adf(&md).unwrap();
11324        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11325        let mark_types: Vec<&str> = node
11326            .marks
11327            .as_ref()
11328            .unwrap()
11329            .iter()
11330            .map(|m| m.mark_type.as_str())
11331            .collect();
11332        assert_eq!(
11333            mark_types,
11334            vec!["underline", "link"],
11335            "mark order should be preserved, got: {mark_types:?}"
11336        );
11337    }
11338
11339    #[test]
11340    fn mark_ordering_textcolor_link_preserved() {
11341        // Issue #403: [textColor, link] order must be preserved
11342        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11343          {"type":"text","text":"red link","marks":[
11344            {"type":"textColor","attrs":{"color":"#ff0000"}},
11345            {"type":"link","attrs":{"href":"https://example.com"}}
11346          ]}
11347        ]}]}"##;
11348        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11349        let md = adf_to_markdown(&doc).unwrap();
11350        let round_tripped = markdown_to_adf(&md).unwrap();
11351        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11352        let mark_types: Vec<&str> = node
11353            .marks
11354            .as_ref()
11355            .unwrap()
11356            .iter()
11357            .map(|m| m.mark_type.as_str())
11358            .collect();
11359        assert_eq!(
11360            mark_types,
11361            vec!["textColor", "link"],
11362            "mark order should be preserved, got: {mark_types:?}"
11363        );
11364    }
11365
11366    #[test]
11367    fn mark_ordering_link_underline_preserved() {
11368        // Issue #403: [link, underline] order must be preserved (link wraps bracketed span)
11369        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11370          {"type":"text","text":"click here","marks":[
11371            {"type":"link","attrs":{"href":"https://example.com"}},
11372            {"type":"underline"}
11373          ]}
11374        ]}]}"#;
11375        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11376        let md = adf_to_markdown(&doc).unwrap();
11377        // Link should wrap the underline bracketed span: [[click here]{underline}](url)
11378        assert!(
11379            md.contains("](https://example.com)"),
11380            "should have link: {md}"
11381        );
11382        assert!(md.contains("underline"), "should have underline: {md}");
11383        let round_tripped = markdown_to_adf(&md).unwrap();
11384        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11385        let mark_types: Vec<&str> = node
11386            .marks
11387            .as_ref()
11388            .unwrap()
11389            .iter()
11390            .map(|m| m.mark_type.as_str())
11391            .collect();
11392        assert_eq!(
11393            mark_types,
11394            vec!["link", "underline"],
11395            "mark order should be preserved, got: {mark_types:?}"
11396        );
11397    }
11398
11399    #[test]
11400    fn mark_ordering_underline_strong_link_preserved() {
11401        // Issue #491: [underline, strong, link] reordered to [strong, underline, link]
11402        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11403          {"type":"text","text":"bold underlined link","marks":[
11404            {"type":"underline"},
11405            {"type":"strong"},
11406            {"type":"link","attrs":{"href":"https://example.com/page"}}
11407          ]}
11408        ]}]}"#;
11409        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11410        let md = adf_to_markdown(&doc).unwrap();
11411        let round_tripped = markdown_to_adf(&md).unwrap();
11412        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11413        let mark_types: Vec<&str> = node
11414            .marks
11415            .as_ref()
11416            .unwrap()
11417            .iter()
11418            .map(|m| m.mark_type.as_str())
11419            .collect();
11420        assert_eq!(
11421            mark_types,
11422            vec!["underline", "strong", "link"],
11423            "mark order should be preserved, got: {mark_types:?}"
11424        );
11425    }
11426
11427    #[test]
11428    fn mark_ordering_strong_underline_link_preserved() {
11429        // Issue #491: verify [strong, underline, link] is preserved
11430        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11431          {"type":"text","text":"bold underlined link","marks":[
11432            {"type":"strong"},
11433            {"type":"underline"},
11434            {"type":"link","attrs":{"href":"https://example.com/page"}}
11435          ]}
11436        ]}]}"#;
11437        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11438        let md = adf_to_markdown(&doc).unwrap();
11439        let round_tripped = markdown_to_adf(&md).unwrap();
11440        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11441        let mark_types: Vec<&str> = node
11442            .marks
11443            .as_ref()
11444            .unwrap()
11445            .iter()
11446            .map(|m| m.mark_type.as_str())
11447            .collect();
11448        assert_eq!(
11449            mark_types,
11450            vec!["strong", "underline", "link"],
11451            "mark order should be preserved, got: {mark_types:?}"
11452        );
11453    }
11454
11455    #[test]
11456    fn mark_ordering_underline_em_link_preserved() {
11457        // Issue #491: verify [underline, em, link] is preserved
11458        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11459          {"type":"text","text":"italic underlined link","marks":[
11460            {"type":"underline"},
11461            {"type":"em"},
11462            {"type":"link","attrs":{"href":"https://example.com/page"}}
11463          ]}
11464        ]}]}"#;
11465        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11466        let md = adf_to_markdown(&doc).unwrap();
11467        let round_tripped = markdown_to_adf(&md).unwrap();
11468        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11469        let mark_types: Vec<&str> = node
11470            .marks
11471            .as_ref()
11472            .unwrap()
11473            .iter()
11474            .map(|m| m.mark_type.as_str())
11475            .collect();
11476        assert_eq!(
11477            mark_types,
11478            vec!["underline", "em", "link"],
11479            "mark order should be preserved, got: {mark_types:?}"
11480        );
11481    }
11482
11483    #[test]
11484    fn mark_ordering_underline_strike_link_preserved() {
11485        // Issue #491: verify [underline, strike, link] is preserved
11486        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11487          {"type":"text","text":"struck underlined link","marks":[
11488            {"type":"underline"},
11489            {"type":"strike"},
11490            {"type":"link","attrs":{"href":"https://example.com/page"}}
11491          ]}
11492        ]}]}"#;
11493        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11494        let md = adf_to_markdown(&doc).unwrap();
11495        let round_tripped = markdown_to_adf(&md).unwrap();
11496        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11497        let mark_types: Vec<&str> = node
11498            .marks
11499            .as_ref()
11500            .unwrap()
11501            .iter()
11502            .map(|m| m.mark_type.as_str())
11503            .collect();
11504        assert_eq!(
11505            mark_types,
11506            vec!["underline", "strike", "link"],
11507            "mark order should be preserved, got: {mark_types:?}"
11508        );
11509    }
11510
11511    #[test]
11512    fn mark_ordering_underline_strong_em_link_preserved() {
11513        // Issue #491: verify four-mark combo [underline, strong, em, link] is preserved
11514        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11515          {"type":"text","text":"all the marks","marks":[
11516            {"type":"underline"},
11517            {"type":"strong"},
11518            {"type":"em"},
11519            {"type":"link","attrs":{"href":"https://example.com/page"}}
11520          ]}
11521        ]}]}"#;
11522        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11523        let md = adf_to_markdown(&doc).unwrap();
11524        let round_tripped = markdown_to_adf(&md).unwrap();
11525        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11526        let mark_types: Vec<&str> = node
11527            .marks
11528            .as_ref()
11529            .unwrap()
11530            .iter()
11531            .map(|m| m.mark_type.as_str())
11532            .collect();
11533        assert_eq!(
11534            mark_types,
11535            vec!["underline", "strong", "em", "link"],
11536            "mark order should be preserved, got: {mark_types:?}"
11537        );
11538    }
11539
11540    #[test]
11541    fn em_strong_round_trip() {
11542        // Issue #401: em mark dropped when combined with strong
11543        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11544          {"type":"text","text":"bold and italic","marks":[{"type":"strong"},{"type":"em"}]}
11545        ]}]}"#;
11546        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11547        let md = adf_to_markdown(&doc).unwrap();
11548        assert_eq!(md.trim(), "***bold and italic***");
11549        let round_tripped = markdown_to_adf(&md).unwrap();
11550        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11551        assert_eq!(node.text.as_deref(), Some("bold and italic"));
11552        let mark_types: Vec<&str> = node
11553            .marks
11554            .as_ref()
11555            .unwrap()
11556            .iter()
11557            .map(|m| m.mark_type.as_str())
11558            .collect();
11559        assert_eq!(
11560            mark_types,
11561            vec!["strong", "em"],
11562            "both strong and em marks should be preserved, got: {mark_types:?}"
11563        );
11564    }
11565
11566    #[test]
11567    fn em_strong_round_trip_em_first() {
11568        // Issue #549: [em, strong] mark order must be preserved on round-trip.
11569        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11570          {"type":"text","text":"italic and bold","marks":[{"type":"em"},{"type":"strong"}]}
11571        ]}]}"#;
11572        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11573        let md = adf_to_markdown(&doc).unwrap();
11574        let round_tripped = markdown_to_adf(&md).unwrap();
11575        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11576        assert_eq!(node.text.as_deref(), Some("italic and bold"));
11577        let mark_types: Vec<&str> = node
11578            .marks
11579            .as_ref()
11580            .unwrap()
11581            .iter()
11582            .map(|m| m.mark_type.as_str())
11583            .collect();
11584        assert_eq!(
11585            mark_types,
11586            vec!["em", "strong"],
11587            "mark order [em, strong] should be preserved, got: {mark_types:?}"
11588        );
11589    }
11590
11591    /// Round-trips an inline text node with the given marks through ADF → JFM → ADF
11592    /// and asserts the resulting mark types match `expected`.
11593    fn assert_mark_order_round_trip(marks: Vec<AdfMark>, expected: &[&str]) {
11594        let doc = AdfDocument {
11595            version: 1,
11596            doc_type: "doc".to_string(),
11597            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11598                "text", marks,
11599            )])],
11600        };
11601        let md = adf_to_markdown(&doc).unwrap();
11602        let round_tripped = markdown_to_adf(&md).unwrap();
11603        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11604        let mark_types: Vec<&str> = node
11605            .marks
11606            .as_ref()
11607            .expect("round-tripped node should have marks")
11608            .iter()
11609            .map(|m| m.mark_type.as_str())
11610            .collect();
11611        assert_eq!(
11612            mark_types, expected,
11613            "marks should round-trip in order via {md:?}"
11614        );
11615    }
11616
11617    #[test]
11618    fn round_trip_em_strong_mark_order() {
11619        // Issue #549: em + strong in either order must round-trip.
11620        assert_mark_order_round_trip(vec![AdfMark::em(), AdfMark::strong()], &["em", "strong"]);
11621        assert_mark_order_round_trip(vec![AdfMark::strong(), AdfMark::em()], &["strong", "em"]);
11622    }
11623
11624    #[test]
11625    fn round_trip_strong_underline_mark_order() {
11626        // Issue #549: strong + underline in either order must round-trip.
11627        assert_mark_order_round_trip(
11628            vec![AdfMark::strong(), AdfMark::underline()],
11629            &["strong", "underline"],
11630        );
11631        assert_mark_order_round_trip(
11632            vec![AdfMark::underline(), AdfMark::strong()],
11633            &["underline", "strong"],
11634        );
11635    }
11636
11637    #[test]
11638    fn round_trip_em_underline_mark_order() {
11639        assert_mark_order_round_trip(
11640            vec![AdfMark::em(), AdfMark::underline()],
11641            &["em", "underline"],
11642        );
11643        assert_mark_order_round_trip(
11644            vec![AdfMark::underline(), AdfMark::em()],
11645            &["underline", "em"],
11646        );
11647    }
11648
11649    #[test]
11650    fn round_trip_strike_strong_em_permutations() {
11651        // Each permutation of {strike, strong, em} must round-trip the mark order
11652        // exactly, because the Atlassian ADF spec does not define a canonical mark
11653        // ordering and we preserve whatever ordering Jira delivered.
11654        assert_mark_order_round_trip(
11655            vec![AdfMark::strike(), AdfMark::strong(), AdfMark::em()],
11656            &["strike", "strong", "em"],
11657        );
11658        assert_mark_order_round_trip(
11659            vec![AdfMark::strike(), AdfMark::em(), AdfMark::strong()],
11660            &["strike", "em", "strong"],
11661        );
11662        assert_mark_order_round_trip(
11663            vec![AdfMark::strong(), AdfMark::strike(), AdfMark::em()],
11664            &["strong", "strike", "em"],
11665        );
11666        assert_mark_order_round_trip(
11667            vec![AdfMark::strong(), AdfMark::em(), AdfMark::strike()],
11668            &["strong", "em", "strike"],
11669        );
11670        assert_mark_order_round_trip(
11671            vec![AdfMark::em(), AdfMark::strike(), AdfMark::strong()],
11672            &["em", "strike", "strong"],
11673        );
11674        assert_mark_order_round_trip(
11675            vec![AdfMark::em(), AdfMark::strong(), AdfMark::strike()],
11676            &["em", "strong", "strike"],
11677        );
11678    }
11679
11680    #[test]
11681    fn round_trip_underline_nested_with_strong_em() {
11682        // Underline may sit outside, between, or inside strong/em — each position
11683        // must round-trip.
11684        assert_mark_order_round_trip(
11685            vec![AdfMark::underline(), AdfMark::strong(), AdfMark::em()],
11686            &["underline", "strong", "em"],
11687        );
11688        assert_mark_order_round_trip(
11689            vec![AdfMark::strong(), AdfMark::underline(), AdfMark::em()],
11690            &["strong", "underline", "em"],
11691        );
11692        assert_mark_order_round_trip(
11693            vec![AdfMark::strong(), AdfMark::em(), AdfMark::underline()],
11694            &["strong", "em", "underline"],
11695        );
11696    }
11697
11698    #[test]
11699    fn round_trip_span_attr_order_preserved() {
11700        // Issue #549: the `:span` directive always parses color/bg/subsup
11701        // attrs in a fixed order, so non-canonical orderings must be emitted
11702        // as nested :span wrappers rather than a single merged wrapper.
11703        assert_mark_order_round_trip(
11704            vec![
11705                AdfMark::background_color("#ffff00"),
11706                AdfMark::text_color("#ff0000"),
11707            ],
11708            &["backgroundColor", "textColor"],
11709        );
11710        assert_mark_order_round_trip(
11711            vec![AdfMark::subsup("sub"), AdfMark::text_color("#ff0000")],
11712            &["subsup", "textColor"],
11713        );
11714        assert_mark_order_round_trip(
11715            vec![
11716                AdfMark::text_color("#ff0000"),
11717                AdfMark::background_color("#ffff00"),
11718            ],
11719            &["textColor", "backgroundColor"],
11720        );
11721    }
11722
11723    #[test]
11724    fn round_trip_annotation_before_underline() {
11725        // Issue #549: the bracketed-span parser reads `underline` before any
11726        // annotation-ids, so `[annotation, underline]` must be emitted as
11727        // nested wrappers rather than one merged `[text]{underline annotation-id=X}`.
11728        assert_mark_order_round_trip(
11729            vec![
11730                AdfMark::annotation("ann-1", "inlineComment"),
11731                AdfMark::underline(),
11732            ],
11733            &["annotation", "underline"],
11734        );
11735        assert_mark_order_round_trip(
11736            vec![
11737                AdfMark::annotation("ann-1", "inlineComment"),
11738                AdfMark::underline(),
11739                AdfMark::annotation("ann-2", "inlineComment"),
11740            ],
11741            &["annotation", "underline", "annotation"],
11742        );
11743    }
11744
11745    #[test]
11746    fn round_trip_em_content_with_underscores() {
11747        // When em renders as `_..._` (to disambiguate from strong), any literal
11748        // underscores in the text must be escaped so they don't close the
11749        // emphasis span early.  Text like "foo_bar_baz" with [em, strong] must
11750        // survive round-trip with the underscores intact.
11751        let doc = AdfDocument {
11752            version: 1,
11753            doc_type: "doc".to_string(),
11754            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11755                "foo _bar_ baz",
11756                vec![AdfMark::em(), AdfMark::strong()],
11757            )])],
11758        };
11759        let md = adf_to_markdown(&doc).unwrap();
11760        let round_tripped = markdown_to_adf(&md).unwrap();
11761        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11762        assert_eq!(node.text.as_deref(), Some("foo _bar_ baz"));
11763        let mark_types: Vec<&str> = node
11764            .marks
11765            .as_ref()
11766            .unwrap()
11767            .iter()
11768            .map(|m| m.mark_type.as_str())
11769            .collect();
11770        assert_eq!(mark_types, vec!["em", "strong"]);
11771    }
11772
11773    #[test]
11774    fn round_trip_link_nested_with_formatting_marks() {
11775        // Link may sit at any position in the marks array relative to em,
11776        // strong, strike, and underline — each position must round-trip.
11777        assert_mark_order_round_trip(
11778            vec![
11779                AdfMark::link("https://example.com"),
11780                AdfMark::strong(),
11781                AdfMark::em(),
11782            ],
11783            &["link", "strong", "em"],
11784        );
11785        assert_mark_order_round_trip(
11786            vec![
11787                AdfMark::em(),
11788                AdfMark::strong(),
11789                AdfMark::link("https://example.com"),
11790            ],
11791            &["em", "strong", "link"],
11792        );
11793        assert_mark_order_round_trip(
11794            vec![
11795                AdfMark::underline(),
11796                AdfMark::link("https://example.com"),
11797                AdfMark::strong(),
11798            ],
11799            &["underline", "link", "strong"],
11800        );
11801    }
11802
11803    /// Builds an `AdfMark` with the given type and no attrs, bypassing the
11804    /// usual constructors so we can exercise the defensive branches in the
11805    /// render helpers (the constructors always populate `attrs`).
11806    fn bare_mark(mark_type: &str) -> AdfMark {
11807        AdfMark {
11808            mark_type: mark_type.to_string(),
11809            attrs: None,
11810        }
11811    }
11812
11813    #[test]
11814    fn collect_span_attr_handles_missing_attrs() {
11815        // `textColor`/`backgroundColor`/`subsup` marks without the expected
11816        // `color`/`type` attr must not emit a fragment (the `if let` falls
11817        // through without pushing).  This exercises the inner-None branches
11818        // that the typed-constructor tests otherwise skip.
11819        let mut attrs = Vec::new();
11820        collect_span_attr(&bare_mark("textColor"), &mut attrs);
11821        collect_span_attr(&bare_mark("backgroundColor"), &mut attrs);
11822        collect_span_attr(&bare_mark("subsup"), &mut attrs);
11823        collect_span_attr(&bare_mark("link"), &mut attrs);
11824        assert!(attrs.is_empty(), "got: {attrs:?}");
11825    }
11826
11827    #[test]
11828    fn collect_bracketed_attr_handles_missing_attrs() {
11829        // An annotation mark with no attrs map at all must silently produce
11830        // no fragments — this covers the outer `if let Some(ref a)` None arm.
11831        let mut attrs = Vec::new();
11832        collect_bracketed_attr(&bare_mark("annotation"), &mut attrs);
11833        collect_bracketed_attr(&bare_mark("strong"), &mut attrs);
11834        assert!(attrs.is_empty(), "got: {attrs:?}");
11835    }
11836
11837    #[test]
11838    fn collect_bracketed_attr_handles_annotation_without_id() {
11839        // An annotation mark with attrs present but missing `id` and
11840        // `annotationType` keys still emits nothing — exercises the inner
11841        // None branches of each `if let` in the annotation arm.
11842        let mark = AdfMark {
11843            mark_type: "annotation".to_string(),
11844            attrs: Some(serde_json::json!({})),
11845        };
11846        let mut attrs = Vec::new();
11847        collect_bracketed_attr(&mark, &mut attrs);
11848        assert!(attrs.is_empty(), "got: {attrs:?}");
11849    }
11850
11851    #[test]
11852    fn span_attr_order_rejects_unknown_types() {
11853        // `span_attr_order` must classify unknown mark types as the sentinel
11854        // value, and `span_run_is_canonical` must reject a run that contains
11855        // any such unknown type.
11856        assert_eq!(span_attr_order("textColor"), 0);
11857        assert_eq!(span_attr_order("backgroundColor"), 1);
11858        assert_eq!(span_attr_order("subsup"), 2);
11859        assert_eq!(span_attr_order("strong"), u8::MAX);
11860        assert!(!span_run_is_canonical(&[bare_mark("strong")]));
11861    }
11862
11863    #[test]
11864    fn bracketed_run_rejects_unknown_types() {
11865        // `bracketed_run_is_canonical` only accepts `underline` and
11866        // `annotation`; any other mark type in the run short-circuits to
11867        // `false` so the caller emits nested wrappers.
11868        assert!(bracketed_run_is_canonical(&[
11869            AdfMark::underline(),
11870            AdfMark::annotation("x", "inlineComment")
11871        ]));
11872        assert!(!bracketed_run_is_canonical(&[
11873            AdfMark::annotation("x", "inlineComment"),
11874            AdfMark::underline()
11875        ]));
11876        assert!(!bracketed_run_is_canonical(&[bare_mark("strong")]));
11877    }
11878
11879    #[test]
11880    fn render_marked_text_ignores_unknown_mark_types() {
11881        // Unknown mark types fall through `render_marked_text`'s `_ =>`
11882        // arm and are dropped; the rendered JFM must still produce the
11883        // underlying text (and round-trip back to an unmarked text node).
11884        let doc = AdfDocument {
11885            version: 1,
11886            doc_type: "doc".to_string(),
11887            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11888                "hello",
11889                vec![bare_mark("futureMark"), AdfMark::strong()],
11890            )])],
11891        };
11892        let md = adf_to_markdown(&doc).unwrap();
11893        assert_eq!(md.trim(), "**hello**");
11894        let rt = markdown_to_adf(&md).unwrap();
11895        let node = &rt.content[0].content.as_ref().unwrap()[0];
11896        assert_eq!(node.text.as_deref(), Some("hello"));
11897        let mark_types: Vec<&str> = node
11898            .marks
11899            .as_ref()
11900            .unwrap()
11901            .iter()
11902            .map(|m| m.mark_type.as_str())
11903            .collect();
11904        assert_eq!(mark_types, vec!["strong"]);
11905    }
11906
11907    #[test]
11908    fn triple_asterisk_parse_to_adf() {
11909        // Issue #401: ***text*** should parse as text with strong+em marks
11910        let md = "***bold and italic***\n";
11911        let doc = markdown_to_adf(md).unwrap();
11912        let node = &doc.content[0].content.as_ref().unwrap()[0];
11913        assert_eq!(node.text.as_deref(), Some("bold and italic"));
11914        let mark_types: Vec<&str> = node
11915            .marks
11916            .as_ref()
11917            .unwrap()
11918            .iter()
11919            .map(|m| m.mark_type.as_str())
11920            .collect();
11921        assert!(
11922            mark_types.contains(&"strong") && mark_types.contains(&"em"),
11923            "***text*** should produce both strong and em marks, got: {mark_types:?}"
11924        );
11925    }
11926
11927    #[test]
11928    fn triple_asterisk_with_surrounding_text() {
11929        // Issue #401: surrounding text should not be affected
11930        let md = "before ***bold italic*** after\n";
11931        let doc = markdown_to_adf(md).unwrap();
11932        let nodes = doc.content[0].content.as_ref().unwrap();
11933        // Should have: "before " (plain), "bold italic" (strong+em), " after" (plain)
11934        assert!(
11935            nodes.len() >= 3,
11936            "expected at least 3 nodes, got {}",
11937            nodes.len()
11938        );
11939        assert_eq!(nodes[0].text.as_deref(), Some("before "));
11940        assert_eq!(nodes[1].text.as_deref(), Some("bold italic"));
11941        let mark_types: Vec<&str> = nodes[1]
11942            .marks
11943            .as_ref()
11944            .unwrap()
11945            .iter()
11946            .map(|m| m.mark_type.as_str())
11947            .collect();
11948        assert!(
11949            mark_types.contains(&"strong") && mark_types.contains(&"em"),
11950            "middle node should have strong+em, got: {mark_types:?}"
11951        );
11952        assert_eq!(nodes[2].text.as_deref(), Some(" after"));
11953    }
11954
11955    #[test]
11956    fn annotation_mark_round_trip() {
11957        // Issue #364: annotation marks were silently dropped
11958        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11959          {"type":"text","text":"highlighted text","marks":[
11960            {"type":"annotation","attrs":{"id":"abc123","annotationType":"inlineComment"}}
11961          ]}
11962        ]}]}"#;
11963        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11964
11965        let md = adf_to_markdown(&doc).unwrap();
11966        assert!(
11967            md.contains("annotation-id="),
11968            "JFM should contain annotation-id, got: {md}"
11969        );
11970
11971        let round_tripped = markdown_to_adf(&md).unwrap();
11972        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11973        assert_eq!(text_node.text.as_deref(), Some("highlighted text"));
11974        let marks = text_node.marks.as_ref().expect("should have marks");
11975        let ann = marks
11976            .iter()
11977            .find(|m| m.mark_type == "annotation")
11978            .expect("should have annotation mark");
11979        let attrs = ann.attrs.as_ref().unwrap();
11980        assert_eq!(attrs["id"], "abc123");
11981        assert_eq!(attrs["annotationType"], "inlineComment");
11982    }
11983
11984    #[test]
11985    fn annotation_mark_with_bold() {
11986        // Annotation + bold should both survive round-trip
11987        let doc = AdfDocument {
11988            version: 1,
11989            doc_type: "doc".to_string(),
11990            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11991                "bold comment",
11992                vec![
11993                    AdfMark::strong(),
11994                    AdfMark::annotation("def456", "inlineComment"),
11995                ],
11996            )])],
11997        };
11998        let md = adf_to_markdown(&doc).unwrap();
11999        let round_tripped = markdown_to_adf(&md).unwrap();
12000        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12001        let marks = text_node.marks.as_ref().expect("should have marks");
12002        assert!(
12003            marks.iter().any(|m| m.mark_type == "strong"),
12004            "should have strong mark"
12005        );
12006        assert!(
12007            marks.iter().any(|m| m.mark_type == "annotation"),
12008            "should have annotation mark"
12009        );
12010    }
12011
12012    #[test]
12013    fn annotation_and_link_marks_both_preserved() {
12014        // Issue #390: text with both annotation and link marks loses link on round-trip
12015        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12016          {"type":"text","text":"HANGUL-8","marks":[
12017            {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"5ca7425e-34cd-48d3-b4eb-9873ac8b20e0"}},
12018            {"type":"link","attrs":{"href":"https://zd.atlassian.net/browse/HANG-8"}}
12019          ]}
12020        ]}]}"#;
12021        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12022        let md = adf_to_markdown(&doc).unwrap();
12023        // Should contain both annotation attrs and link syntax
12024        assert!(
12025            md.contains("annotation-id="),
12026            "JFM should contain annotation-id, got: {md}"
12027        );
12028        assert!(
12029            md.contains("](https://"),
12030            "JFM should contain link href, got: {md}"
12031        );
12032        let round_tripped = markdown_to_adf(&md).unwrap();
12033        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12034        let marks = text_node.marks.as_ref().expect("should have marks");
12035        assert!(
12036            marks.iter().any(|m| m.mark_type == "annotation"),
12037            "should have annotation mark, got: {:?}",
12038            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12039        );
12040        assert!(
12041            marks.iter().any(|m| m.mark_type == "link"),
12042            "should have link mark, got: {:?}",
12043            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12044        );
12045    }
12046
12047    #[test]
12048    fn annotation_and_code_marks_both_preserved() {
12049        // Issue #508: annotation mark dropped when combined with code mark
12050        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12051          {"type":"text","text":"some text with "},
12052          {"type":"text","text":"annotated code","marks":[
12053            {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"aabbccdd-1234-5678-abcd-000000000001"}},
12054            {"type":"code"}
12055          ]},
12056          {"type":"text","text":" remaining text"}
12057        ]}]}"#;
12058        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12059        let md = adf_to_markdown(&doc).unwrap();
12060        assert!(
12061            md.contains("annotation-id="),
12062            "JFM should contain annotation-id, got: {md}"
12063        );
12064        assert!(
12065            md.contains('`'),
12066            "JFM should contain backticks for code, got: {md}"
12067        );
12068
12069        let round_tripped = markdown_to_adf(&md).unwrap();
12070        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12071        // Find the text node with "annotated code"
12072        let code_node = nodes
12073            .iter()
12074            .find(|n| n.text.as_deref() == Some("annotated code"))
12075            .expect("should have 'annotated code' text node");
12076        let marks = code_node.marks.as_ref().expect("should have marks");
12077        assert!(
12078            marks.iter().any(|m| m.mark_type == "annotation"),
12079            "should have annotation mark, got: {:?}",
12080            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12081        );
12082        assert!(
12083            marks.iter().any(|m| m.mark_type == "code"),
12084            "should have code mark, got: {:?}",
12085            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12086        );
12087        let ann = marks.iter().find(|m| m.mark_type == "annotation").unwrap();
12088        let attrs = ann.attrs.as_ref().unwrap();
12089        assert_eq!(attrs["id"], "aabbccdd-1234-5678-abcd-000000000001");
12090        assert_eq!(attrs["annotationType"], "inlineComment");
12091    }
12092
12093    #[test]
12094    fn annotation_and_code_and_link_marks_all_preserved() {
12095        // annotation + code + link should all survive round-trip
12096        let doc = AdfDocument {
12097            version: 1,
12098            doc_type: "doc".to_string(),
12099            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12100                "linked code",
12101                vec![
12102                    AdfMark::annotation("ann-001", "inlineComment"),
12103                    AdfMark::code(),
12104                    AdfMark::link("https://example.com"),
12105                ],
12106            )])],
12107        };
12108        let md = adf_to_markdown(&doc).unwrap();
12109        assert!(
12110            md.contains("annotation-id="),
12111            "JFM should contain annotation-id, got: {md}"
12112        );
12113        assert!(md.contains('`'), "JFM should contain backticks, got: {md}");
12114        assert!(
12115            md.contains("](https://example.com)"),
12116            "JFM should contain link, got: {md}"
12117        );
12118
12119        let round_tripped = markdown_to_adf(&md).unwrap();
12120        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12121        let marks = text_node.marks.as_ref().expect("should have marks");
12122        assert!(
12123            marks.iter().any(|m| m.mark_type == "annotation"),
12124            "should have annotation mark, got: {:?}",
12125            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12126        );
12127        assert!(
12128            marks.iter().any(|m| m.mark_type == "code"),
12129            "should have code mark, got: {:?}",
12130            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12131        );
12132        assert!(
12133            marks.iter().any(|m| m.mark_type == "link"),
12134            "should have link mark, got: {:?}",
12135            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12136        );
12137    }
12138
12139    #[test]
12140    fn multiple_annotations_and_code_mark_preserved() {
12141        // Multiple annotation marks on a code node should all survive
12142        let doc = AdfDocument {
12143            version: 1,
12144            doc_type: "doc".to_string(),
12145            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12146                "doubly annotated",
12147                vec![
12148                    AdfMark::annotation("ann-aaa", "inlineComment"),
12149                    AdfMark::annotation("ann-bbb", "inlineComment"),
12150                    AdfMark::code(),
12151                ],
12152            )])],
12153        };
12154        let md = adf_to_markdown(&doc).unwrap();
12155        assert!(
12156            md.contains("ann-aaa"),
12157            "JFM should contain first annotation id, got: {md}"
12158        );
12159        assert!(
12160            md.contains("ann-bbb"),
12161            "JFM should contain second annotation id, got: {md}"
12162        );
12163
12164        let round_tripped = markdown_to_adf(&md).unwrap();
12165        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12166        let marks = text_node.marks.as_ref().expect("should have marks");
12167        let ann_marks: Vec<_> = marks
12168            .iter()
12169            .filter(|m| m.mark_type == "annotation")
12170            .collect();
12171        assert_eq!(
12172            ann_marks.len(),
12173            2,
12174            "should have 2 annotation marks, got: {}",
12175            ann_marks.len()
12176        );
12177        assert!(
12178            marks.iter().any(|m| m.mark_type == "code"),
12179            "should have code mark"
12180        );
12181    }
12182
12183    #[test]
12184    fn underline_and_link_marks_both_preserved() {
12185        // Underline + link should also coexist
12186        let doc = AdfDocument {
12187            version: 1,
12188            doc_type: "doc".to_string(),
12189            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12190                "click here",
12191                vec![AdfMark::underline(), AdfMark::link("https://example.com")],
12192            )])],
12193        };
12194        let md = adf_to_markdown(&doc).unwrap();
12195        assert!(md.contains("underline"), "should have underline attr: {md}");
12196        assert!(
12197            md.contains("](https://example.com)"),
12198            "should have link: {md}"
12199        );
12200        let round_tripped = markdown_to_adf(&md).unwrap();
12201        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12202        let marks = text_node.marks.as_ref().expect("should have marks");
12203        assert!(marks.iter().any(|m| m.mark_type == "underline"));
12204        assert!(marks.iter().any(|m| m.mark_type == "link"));
12205    }
12206
12207    #[test]
12208    fn annotation_link_and_bold_all_preserved() {
12209        // All three marks should coexist
12210        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12211          {"type":"text","text":"important","marks":[
12212            {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"abc"}},
12213            {"type":"link","attrs":{"href":"https://example.com"}},
12214            {"type":"strong"}
12215          ]}
12216        ]}]}"#;
12217        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12218        let md = adf_to_markdown(&doc).unwrap();
12219        let round_tripped = markdown_to_adf(&md).unwrap();
12220        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12221        let marks = text_node.marks.as_ref().expect("should have marks");
12222        assert!(
12223            marks.iter().any(|m| m.mark_type == "annotation"),
12224            "should have annotation"
12225        );
12226        assert!(
12227            marks.iter().any(|m| m.mark_type == "link"),
12228            "should have link"
12229        );
12230        assert!(
12231            marks.iter().any(|m| m.mark_type == "strong"),
12232            "should have strong"
12233        );
12234    }
12235
12236    #[test]
12237    fn multiple_annotation_marks_round_trip() {
12238        // Issue #439: multiple annotation marks on same text node — all but last dropped
12239        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12240          {"type":"text","text":"some annotated text","marks":[
12241            {"type":"annotation","attrs":{"id":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","annotationType":"inlineComment"}},
12242            {"type":"annotation","attrs":{"id":"ffffffff-1111-2222-3333-444444444444","annotationType":"inlineComment"}}
12243          ]}
12244        ]}]}"#;
12245        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12246
12247        let md = adf_to_markdown(&doc).unwrap();
12248        assert!(
12249            md.contains("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"),
12250            "JFM should contain first annotation id, got: {md}"
12251        );
12252        assert!(
12253            md.contains("ffffffff-1111-2222-3333-444444444444"),
12254            "JFM should contain second annotation id, got: {md}"
12255        );
12256
12257        let round_tripped = markdown_to_adf(&md).unwrap();
12258        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12259        assert_eq!(text_node.text.as_deref(), Some("some annotated text"));
12260        let marks = text_node.marks.as_ref().expect("should have marks");
12261        let annotations: Vec<_> = marks
12262            .iter()
12263            .filter(|m| m.mark_type == "annotation")
12264            .collect();
12265        assert_eq!(
12266            annotations.len(),
12267            2,
12268            "should have 2 annotation marks, got: {annotations:?}"
12269        );
12270        let ids: Vec<_> = annotations
12271            .iter()
12272            .map(|a| a.attrs.as_ref().unwrap()["id"].as_str().unwrap())
12273            .collect();
12274        assert!(ids.contains(&"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"));
12275        assert!(ids.contains(&"ffffffff-1111-2222-3333-444444444444"));
12276    }
12277
12278    #[test]
12279    fn three_annotation_marks_round_trip() {
12280        // Verify three overlapping annotations all survive
12281        let doc = AdfDocument {
12282            version: 1,
12283            doc_type: "doc".to_string(),
12284            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12285                "triple annotated",
12286                vec![
12287                    AdfMark::annotation("id-1", "inlineComment"),
12288                    AdfMark::annotation("id-2", "inlineComment"),
12289                    AdfMark::annotation("id-3", "inlineComment"),
12290                ],
12291            )])],
12292        };
12293        let md = adf_to_markdown(&doc).unwrap();
12294        let round_tripped = markdown_to_adf(&md).unwrap();
12295        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12296        let marks = text_node.marks.as_ref().expect("should have marks");
12297        let annotations: Vec<_> = marks
12298            .iter()
12299            .filter(|m| m.mark_type == "annotation")
12300            .collect();
12301        assert_eq!(
12302            annotations.len(),
12303            3,
12304            "should have 3 annotation marks, got: {annotations:?}"
12305        );
12306    }
12307
12308    #[test]
12309    fn multiple_annotations_with_bold_round_trip() {
12310        // Multiple annotations + bold should all survive
12311        let doc = AdfDocument {
12312            version: 1,
12313            doc_type: "doc".to_string(),
12314            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12315                "bold double annotated",
12316                vec![
12317                    AdfMark::strong(),
12318                    AdfMark::annotation("ann-a", "inlineComment"),
12319                    AdfMark::annotation("ann-b", "inlineComment"),
12320                ],
12321            )])],
12322        };
12323        let md = adf_to_markdown(&doc).unwrap();
12324        let round_tripped = markdown_to_adf(&md).unwrap();
12325        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12326        let marks = text_node.marks.as_ref().expect("should have marks");
12327        assert!(
12328            marks.iter().any(|m| m.mark_type == "strong"),
12329            "should have strong mark"
12330        );
12331        let annotations: Vec<_> = marks
12332            .iter()
12333            .filter(|m| m.mark_type == "annotation")
12334            .collect();
12335        assert_eq!(
12336            annotations.len(),
12337            2,
12338            "should have 2 annotation marks, got: {annotations:?}"
12339        );
12340    }
12341
12342    #[test]
12343    fn multiple_annotations_with_link_round_trip() {
12344        // Multiple annotations + link should all survive
12345        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12346          {"type":"text","text":"linked text","marks":[
12347            {"type":"annotation","attrs":{"id":"ann-x","annotationType":"inlineComment"}},
12348            {"type":"annotation","attrs":{"id":"ann-y","annotationType":"inlineComment"}},
12349            {"type":"link","attrs":{"href":"https://example.com"}}
12350          ]}
12351        ]}]}"#;
12352        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12353        let md = adf_to_markdown(&doc).unwrap();
12354        let round_tripped = markdown_to_adf(&md).unwrap();
12355        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12356        let marks = text_node.marks.as_ref().expect("should have marks");
12357        assert!(
12358            marks.iter().any(|m| m.mark_type == "link"),
12359            "should have link mark"
12360        );
12361        let annotations: Vec<_> = marks
12362            .iter()
12363            .filter(|m| m.mark_type == "annotation")
12364            .collect();
12365        assert_eq!(
12366            annotations.len(),
12367            2,
12368            "should have 2 annotation marks, got: {annotations:?}"
12369        );
12370    }
12371
12372    // ── Issue #471: annotation marks on non-text inline nodes ─────────
12373
12374    #[test]
12375    fn annotation_on_emoji_round_trip() {
12376        // Issue #471: annotation mark on emoji node should survive round-trip
12377        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12378          {"type":"emoji","attrs":{"id":"1f4dd","shortName":":memo:","text":"📝"},"marks":[
12379            {"type":"annotation","attrs":{"id":"ccddee11-2233-4455-aabb-ccddee112233","annotationType":"inlineComment"}}
12380          ]},
12381          {"type":"text","text":" annotated text","marks":[
12382            {"type":"annotation","attrs":{"id":"ccddee11-2233-4455-aabb-ccddee112233","annotationType":"inlineComment"}}
12383          ]}
12384        ]}]}"#;
12385        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12386        let md = adf_to_markdown(&doc).unwrap();
12387        assert!(
12388            md.contains("annotation-id="),
12389            "JFM should contain annotation-id for emoji, got: {md}"
12390        );
12391
12392        let round_tripped = markdown_to_adf(&md).unwrap();
12393        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12394
12395        // Emoji node should retain annotation mark
12396        let emoji_node = nodes.iter().find(|n| n.node_type == "emoji").unwrap();
12397        let emoji_marks = emoji_node.marks.as_ref().expect("emoji should have marks");
12398        assert!(
12399            emoji_marks.iter().any(|m| m.mark_type == "annotation"),
12400            "emoji should have annotation mark, got: {emoji_marks:?}"
12401        );
12402        let ann = emoji_marks
12403            .iter()
12404            .find(|m| m.mark_type == "annotation")
12405            .unwrap();
12406        assert_eq!(
12407            ann.attrs.as_ref().unwrap()["id"],
12408            "ccddee11-2233-4455-aabb-ccddee112233"
12409        );
12410
12411        // Text node should also retain annotation mark
12412        let text_node = nodes.iter().find(|n| n.node_type == "text").unwrap();
12413        let text_marks = text_node.marks.as_ref().expect("text should have marks");
12414        assert!(
12415            text_marks.iter().any(|m| m.mark_type == "annotation"),
12416            "text should have annotation mark"
12417        );
12418    }
12419
12420    #[test]
12421    fn annotation_on_status_round_trip() {
12422        let mut status = AdfNode::status("In Progress", "blue");
12423        status.marks = Some(vec![AdfMark::annotation("ann-status-1", "inlineComment")]);
12424
12425        let doc = AdfDocument {
12426            version: 1,
12427            doc_type: "doc".to_string(),
12428            content: vec![AdfNode::paragraph(vec![status])],
12429        };
12430        let md = adf_to_markdown(&doc).unwrap();
12431        assert!(
12432            md.contains("annotation-id="),
12433            "JFM should contain annotation-id for status, got: {md}"
12434        );
12435
12436        let round_tripped = markdown_to_adf(&md).unwrap();
12437        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12438        let status_node = nodes.iter().find(|n| n.node_type == "status").unwrap();
12439        let marks = status_node
12440            .marks
12441            .as_ref()
12442            .expect("status should have marks");
12443        assert!(
12444            marks.iter().any(|m| m.mark_type == "annotation"),
12445            "status should have annotation mark, got: {marks:?}"
12446        );
12447    }
12448
12449    #[test]
12450    fn annotation_on_date_round_trip() {
12451        let mut date = AdfNode::date("1704067200000");
12452        date.marks = Some(vec![AdfMark::annotation("ann-date-1", "inlineComment")]);
12453
12454        let doc = AdfDocument {
12455            version: 1,
12456            doc_type: "doc".to_string(),
12457            content: vec![AdfNode::paragraph(vec![date])],
12458        };
12459        let md = adf_to_markdown(&doc).unwrap();
12460        assert!(
12461            md.contains("annotation-id="),
12462            "JFM should contain annotation-id for date, got: {md}"
12463        );
12464
12465        let round_tripped = markdown_to_adf(&md).unwrap();
12466        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12467        let date_node = nodes.iter().find(|n| n.node_type == "date").unwrap();
12468        let marks = date_node.marks.as_ref().expect("date should have marks");
12469        assert!(
12470            marks.iter().any(|m| m.mark_type == "annotation"),
12471            "date should have annotation mark, got: {marks:?}"
12472        );
12473    }
12474
12475    #[test]
12476    fn annotation_on_mention_round_trip() {
12477        let mut mention = AdfNode::mention("user-123", "@Alice");
12478        mention.marks = Some(vec![AdfMark::annotation("ann-mention-1", "inlineComment")]);
12479
12480        let doc = AdfDocument {
12481            version: 1,
12482            doc_type: "doc".to_string(),
12483            content: vec![AdfNode::paragraph(vec![mention])],
12484        };
12485        let md = adf_to_markdown(&doc).unwrap();
12486        assert!(
12487            md.contains("annotation-id="),
12488            "JFM should contain annotation-id for mention, got: {md}"
12489        );
12490
12491        let round_tripped = markdown_to_adf(&md).unwrap();
12492        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12493        let mention_node = nodes.iter().find(|n| n.node_type == "mention").unwrap();
12494        let marks = mention_node
12495            .marks
12496            .as_ref()
12497            .expect("mention should have marks");
12498        assert!(
12499            marks.iter().any(|m| m.mark_type == "annotation"),
12500            "mention should have annotation mark, got: {marks:?}"
12501        );
12502    }
12503
12504    #[test]
12505    fn annotation_on_inline_card_round_trip() {
12506        let mut card = AdfNode::inline_card("https://example.com");
12507        card.marks = Some(vec![AdfMark::annotation("ann-card-1", "inlineComment")]);
12508
12509        let doc = AdfDocument {
12510            version: 1,
12511            doc_type: "doc".to_string(),
12512            content: vec![AdfNode::paragraph(vec![card])],
12513        };
12514        let md = adf_to_markdown(&doc).unwrap();
12515        assert!(
12516            md.contains("annotation-id="),
12517            "JFM should contain annotation-id for inlineCard, got: {md}"
12518        );
12519
12520        let round_tripped = markdown_to_adf(&md).unwrap();
12521        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12522        let card_node = nodes.iter().find(|n| n.node_type == "inlineCard").unwrap();
12523        let marks = card_node
12524            .marks
12525            .as_ref()
12526            .expect("inlineCard should have marks");
12527        assert!(
12528            marks.iter().any(|m| m.mark_type == "annotation"),
12529            "inlineCard should have annotation mark, got: {marks:?}"
12530        );
12531    }
12532
12533    #[test]
12534    fn annotation_on_placeholder_round_trip() {
12535        let mut placeholder = AdfNode::placeholder("Enter text here");
12536        placeholder.marks = Some(vec![AdfMark::annotation("ann-ph-1", "inlineComment")]);
12537
12538        let doc = AdfDocument {
12539            version: 1,
12540            doc_type: "doc".to_string(),
12541            content: vec![AdfNode::paragraph(vec![placeholder])],
12542        };
12543        let md = adf_to_markdown(&doc).unwrap();
12544        assert!(
12545            md.contains("annotation-id="),
12546            "JFM should contain annotation-id for placeholder, got: {md}"
12547        );
12548
12549        let round_tripped = markdown_to_adf(&md).unwrap();
12550        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12551        let ph_node = nodes.iter().find(|n| n.node_type == "placeholder").unwrap();
12552        let marks = ph_node
12553            .marks
12554            .as_ref()
12555            .expect("placeholder should have marks");
12556        assert!(
12557            marks.iter().any(|m| m.mark_type == "annotation"),
12558            "placeholder should have annotation mark, got: {marks:?}"
12559        );
12560    }
12561
12562    #[test]
12563    fn multiple_annotations_on_emoji_round_trip() {
12564        // Multiple annotation marks on a single emoji node
12565        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12566          {"type":"emoji","attrs":{"shortName":":fire:","text":"🔥"},"marks":[
12567            {"type":"annotation","attrs":{"id":"ann-1","annotationType":"inlineComment"}},
12568            {"type":"annotation","attrs":{"id":"ann-2","annotationType":"inlineComment"}}
12569          ]}
12570        ]}]}"#;
12571        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12572        let md = adf_to_markdown(&doc).unwrap();
12573
12574        let round_tripped = markdown_to_adf(&md).unwrap();
12575        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12576        let emoji_node = nodes.iter().find(|n| n.node_type == "emoji").unwrap();
12577        let marks = emoji_node.marks.as_ref().expect("emoji should have marks");
12578        let annotations: Vec<_> = marks
12579            .iter()
12580            .filter(|m| m.mark_type == "annotation")
12581            .collect();
12582        assert_eq!(
12583            annotations.len(),
12584            2,
12585            "emoji should have 2 annotation marks, got: {annotations:?}"
12586        );
12587    }
12588
12589    #[test]
12590    fn emoji_without_annotation_unchanged() {
12591        // Ensure emoji nodes without annotation marks are not affected
12592        let doc = AdfDocument {
12593            version: 1,
12594            doc_type: "doc".to_string(),
12595            content: vec![AdfNode::paragraph(vec![AdfNode::emoji(":fire:")])],
12596        };
12597        let md = adf_to_markdown(&doc).unwrap();
12598        // Should NOT have bracketed span wrapping
12599        assert!(
12600            !md.contains('['),
12601            "emoji without annotation should not be wrapped in brackets, got: {md}"
12602        );
12603        assert!(md.contains(":fire:"));
12604    }
12605
12606    // ── Inline directive tests (Tier 4) ───────────────────────────────
12607
12608    #[test]
12609    fn status_directive() {
12610        let doc = markdown_to_adf("The ticket is :status[In Progress]{color=blue}.").unwrap();
12611        let content = doc.content[0].content.as_ref().unwrap();
12612        assert_eq!(content[1].node_type, "status");
12613        assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "In Progress");
12614        assert_eq!(content[1].attrs.as_ref().unwrap()["color"], "blue");
12615    }
12616
12617    #[test]
12618    fn adf_status_to_markdown() {
12619        let doc = AdfDocument {
12620            version: 1,
12621            doc_type: "doc".to_string(),
12622            content: vec![AdfNode::paragraph(vec![AdfNode::status("Done", "green")])],
12623        };
12624        let md = adf_to_markdown(&doc).unwrap();
12625        assert!(md.contains(":status[Done]{color=green}"));
12626    }
12627
12628    #[test]
12629    fn round_trip_status() {
12630        let md = "The ticket is :status[In Progress]{color=blue}.\n";
12631        let doc = markdown_to_adf(md).unwrap();
12632        let result = adf_to_markdown(&doc).unwrap();
12633        assert!(result.contains(":status[In Progress]{color=blue}"));
12634    }
12635
12636    #[test]
12637    fn status_with_style_and_localid_roundtrips() {
12638        let adf = AdfDocument {
12639            version: 1,
12640            doc_type: "doc".to_string(),
12641            content: vec![AdfNode::paragraph(vec![{
12642                let mut node = AdfNode::status("open", "green");
12643                node.attrs.as_mut().unwrap()["style"] =
12644                    serde_json::Value::String("bold".to_string());
12645                node.attrs.as_mut().unwrap()["localId"] =
12646                    serde_json::Value::String("d2205ca5-84b9-4950-a730-bfe550fc146b".to_string());
12647                node
12648            }])],
12649        };
12650
12651        let md = adf_to_markdown(&adf).unwrap();
12652        assert!(
12653            md.contains("style=bold"),
12654            "Markdown should contain style attr: {md}"
12655        );
12656        assert!(
12657            md.contains("localId=d2205ca5"),
12658            "Markdown should contain localId attr: {md}"
12659        );
12660
12661        let rt = markdown_to_adf(&md).unwrap();
12662        let status = &rt.content[0].content.as_ref().unwrap()[0];
12663        let attrs = status.attrs.as_ref().unwrap();
12664        assert_eq!(attrs["text"], "open");
12665        assert_eq!(attrs["color"], "green");
12666        assert_eq!(attrs["style"], "bold");
12667        assert_eq!(
12668            attrs["localId"], "d2205ca5-84b9-4950-a730-bfe550fc146b",
12669            "localId should be preserved, got: {}",
12670            attrs["localId"]
12671        );
12672    }
12673
12674    #[test]
12675    fn status_without_style_still_works() {
12676        let md = ":status[Done]{color=green}\n";
12677        let doc = markdown_to_adf(md).unwrap();
12678        let status = &doc.content[0].content.as_ref().unwrap()[0];
12679        let attrs = status.attrs.as_ref().unwrap();
12680        assert_eq!(attrs["text"], "Done");
12681        assert_eq!(attrs["color"], "green");
12682        // No style attr — should not be present
12683        assert!(
12684            attrs.get("style").is_none() || attrs["style"].is_null(),
12685            "style should not be set when not provided"
12686        );
12687    }
12688
12689    #[test]
12690    fn strip_local_ids_removes_localid_from_status() {
12691        let adf = AdfDocument {
12692            version: 1,
12693            doc_type: "doc".to_string(),
12694            content: vec![AdfNode::paragraph(vec![{
12695                let mut node = AdfNode::status("open", "green");
12696                node.attrs.as_mut().unwrap()["localId"] =
12697                    serde_json::Value::String("real-uuid-here".to_string());
12698                node
12699            }])],
12700        };
12701        let opts = RenderOptions {
12702            strip_local_ids: true,
12703        };
12704        let md = adf_to_markdown_with_options(&adf, &opts).unwrap();
12705        assert!(
12706            !md.contains("localId"),
12707            "localId should be stripped, got: {md}"
12708        );
12709        assert!(md.contains("color=green"), "color should be preserved");
12710    }
12711
12712    #[test]
12713    fn strip_local_ids_removes_localid_from_table() {
12714        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"}]}]}]}]}]}"#;
12715        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12716        let opts = RenderOptions {
12717            strip_local_ids: true,
12718        };
12719        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12720        assert!(
12721            !md.contains("localId"),
12722            "localId should be stripped from table, got: {md}"
12723        );
12724        assert!(md.contains("layout=default"), "layout should be preserved");
12725    }
12726
12727    #[test]
12728    fn default_options_preserve_localid() {
12729        let adf = AdfDocument {
12730            version: 1,
12731            doc_type: "doc".to_string(),
12732            content: vec![AdfNode::paragraph(vec![{
12733                let mut node = AdfNode::status("open", "green");
12734                node.attrs.as_mut().unwrap()["localId"] =
12735                    serde_json::Value::String("real-uuid-here".to_string());
12736                node
12737            }])],
12738        };
12739        let md = adf_to_markdown(&adf).unwrap();
12740        assert!(
12741            md.contains("localId=real-uuid-here"),
12742            "Default should preserve localId, got: {md}"
12743        );
12744    }
12745
12746    #[test]
12747    fn mention_localid_roundtrip() {
12748        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"mention","attrs":{"id":"user123","text":"@Alice","localId":"m-001"}}]}]}"#;
12749        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12750        let md = adf_to_markdown(&doc).unwrap();
12751        assert!(
12752            md.contains("localId=m-001"),
12753            "mention should have localId in md: {md}"
12754        );
12755        let rt = markdown_to_adf(&md).unwrap();
12756        let mention = &rt.content[0].content.as_ref().unwrap()[0];
12757        assert_eq!(mention.attrs.as_ref().unwrap()["localId"], "m-001");
12758    }
12759
12760    #[test]
12761    fn date_localid_roundtrip() {
12762        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
12763        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12764        let md = adf_to_markdown(&doc).unwrap();
12765        assert!(
12766            md.contains("localId=d-001"),
12767            "date should have localId in md: {md}"
12768        );
12769        let rt = markdown_to_adf(&md).unwrap();
12770        let date = &rt.content[0].content.as_ref().unwrap()[0];
12771        assert_eq!(date.attrs.as_ref().unwrap()["localId"], "d-001");
12772    }
12773
12774    #[test]
12775    fn emoji_localid_roundtrip() {
12776        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"emoji","attrs":{"shortName":":smile:","localId":"e-001"}}]}]}"#;
12777        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12778        let md = adf_to_markdown(&doc).unwrap();
12779        assert!(
12780            md.contains("localId=e-001"),
12781            "emoji should have localId in md: {md}"
12782        );
12783        let rt = markdown_to_adf(&md).unwrap();
12784        let emoji = &rt.content[0].content.as_ref().unwrap()[0];
12785        assert_eq!(emoji.attrs.as_ref().unwrap()["localId"], "e-001");
12786    }
12787
12788    #[test]
12789    fn inline_card_localid_roundtrip() {
12790        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"inlineCard","attrs":{"url":"https://example.com","localId":"c-001"}}]}]}"#;
12791        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12792        let md = adf_to_markdown(&doc).unwrap();
12793        assert!(
12794            md.contains("localId=c-001"),
12795            "inlineCard should have localId in md: {md}"
12796        );
12797        let rt = markdown_to_adf(&md).unwrap();
12798        let card = &rt.content[0].content.as_ref().unwrap()[0];
12799        assert_eq!(card.attrs.as_ref().unwrap()["localId"], "c-001");
12800    }
12801
12802    #[test]
12803    fn strip_local_ids_removes_from_mention() {
12804        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"mention","attrs":{"id":"user123","text":"@Alice","localId":"m-001"}}]}]}"#;
12805        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12806        let opts = RenderOptions {
12807            strip_local_ids: true,
12808        };
12809        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12810        assert!(
12811            !md.contains("localId"),
12812            "localId should be stripped from mention: {md}"
12813        );
12814        assert!(md.contains("id=user123"), "other attrs should be preserved");
12815    }
12816
12817    #[test]
12818    fn strip_local_ids_removes_from_date() {
12819        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
12820        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12821        let opts = RenderOptions {
12822            strip_local_ids: true,
12823        };
12824        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12825        assert!(
12826            !md.contains("localId"),
12827            "localId should be stripped from date: {md}"
12828        );
12829    }
12830
12831    #[test]
12832    fn strip_local_ids_removes_from_block_attrs() {
12833        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"p-001"},"content":[{"type":"text","text":"hello"}]}]}"#;
12834        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12835        let opts = RenderOptions {
12836            strip_local_ids: true,
12837        };
12838        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12839        assert!(
12840            !md.contains("localId"),
12841            "localId should be stripped from block attrs: {md}"
12842        );
12843    }
12844
12845    #[test]
12846    fn table_cell_localid_roundtrip() {
12847        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"}]}]}]}]}]}"#;
12848        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12849        let md = adf_to_markdown(&doc).unwrap();
12850        assert!(
12851            md.contains("localId=tc-001"),
12852            "tableCell should have localId in md: {md}"
12853        );
12854        let rt = markdown_to_adf(&md).unwrap();
12855        let cell = &rt.content[0].content.as_ref().unwrap()[0]
12856            .content
12857            .as_ref()
12858            .unwrap()[0];
12859        assert_eq!(
12860            cell.attrs.as_ref().unwrap()["localId"],
12861            "tc-001",
12862            "tableCell localId should round-trip"
12863        );
12864    }
12865
12866    #[test]
12867    fn table_cell_border_mark_roundtrip() {
12868        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"}]}]}]}]}]}"##;
12869        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12870        let md = adf_to_markdown(&doc).unwrap();
12871        assert!(
12872            md.contains("border-color=#ff000033"),
12873            "tableCell should have border-color in md: {md}"
12874        );
12875        assert!(
12876            md.contains("border-size=2"),
12877            "tableCell should have border-size in md: {md}"
12878        );
12879        let rt = markdown_to_adf(&md).unwrap();
12880        let cell = &rt.content[0].content.as_ref().unwrap()[0]
12881            .content
12882            .as_ref()
12883            .unwrap()[0];
12884        let marks = cell.marks.as_ref().expect("tableCell should have marks");
12885        assert_eq!(marks.len(), 1);
12886        assert_eq!(marks[0].mark_type, "border");
12887        let attrs = marks[0].attrs.as_ref().unwrap();
12888        assert_eq!(attrs["color"], "#ff000033");
12889        assert_eq!(attrs["size"], 2);
12890    }
12891
12892    #[test]
12893    fn table_header_border_mark_roundtrip() {
12894        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"}]}]}]}]}]}"##;
12895        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12896        let md = adf_to_markdown(&doc).unwrap();
12897        assert!(md.contains("border-color=#0000ff"), "md: {md}");
12898        assert!(md.contains("border-size=3"), "md: {md}");
12899        let rt = markdown_to_adf(&md).unwrap();
12900        let cell = &rt.content[0].content.as_ref().unwrap()[0]
12901            .content
12902            .as_ref()
12903            .unwrap()[0];
12904        assert_eq!(cell.node_type, "tableHeader");
12905        let marks = cell.marks.as_ref().expect("tableHeader should have marks");
12906        assert_eq!(marks[0].mark_type, "border");
12907        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#0000ff");
12908        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
12909    }
12910
12911    #[test]
12912    fn table_cell_border_mark_with_attrs_roundtrip() {
12913        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"}]}]}]}]}]}"##;
12914        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12915        let md = adf_to_markdown(&doc).unwrap();
12916        assert!(md.contains("bg=#e6fcff"), "md: {md}");
12917        assert!(md.contains("colspan=2"), "md: {md}");
12918        assert!(md.contains("border-color=#ff000033"), "md: {md}");
12919        let rt = markdown_to_adf(&md).unwrap();
12920        let cell = &rt.content[0].content.as_ref().unwrap()[0]
12921            .content
12922            .as_ref()
12923            .unwrap()[0];
12924        assert_eq!(cell.attrs.as_ref().unwrap()["background"], "#e6fcff");
12925        assert_eq!(cell.attrs.as_ref().unwrap()["colspan"], 2);
12926        let marks = cell.marks.as_ref().expect("should have marks");
12927        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff000033");
12928    }
12929
12930    #[test]
12931    fn table_cell_no_border_mark_unchanged() {
12932        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"}]}]}]}]}]}"#;
12933        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12934        let md = adf_to_markdown(&doc).unwrap();
12935        assert!(
12936            !md.contains("border-color"),
12937            "no border attrs expected: {md}"
12938        );
12939        let rt = markdown_to_adf(&md).unwrap();
12940        let cell = &rt.content[0].content.as_ref().unwrap()[0]
12941            .content
12942            .as_ref()
12943            .unwrap()[0];
12944        assert!(cell.marks.is_none(), "no marks expected on plain cell");
12945    }
12946
12947    #[test]
12948    fn table_cell_border_size_only_defaults_color() {
12949        // border-size without border-color should still produce a border mark
12950        // with the default color
12951        let md = "::::table\n:::tr\n:::td{border-size=3}\ncell\n:::\n:::\n::::\n";
12952        let doc = markdown_to_adf(md).unwrap();
12953        let cell = &doc.content[0].content.as_ref().unwrap()[0]
12954            .content
12955            .as_ref()
12956            .unwrap()[0];
12957        let marks = cell.marks.as_ref().expect("should have border mark");
12958        assert_eq!(marks[0].mark_type, "border");
12959        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#000000");
12960        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
12961    }
12962
12963    #[test]
12964    fn table_cell_border_color_only_defaults_size() {
12965        // border-color without border-size should default size to 1
12966        let md = "::::table\n:::tr\n:::td{border-color=#ff0000}\ncell\n:::\n:::\n::::\n";
12967        let doc = markdown_to_adf(md).unwrap();
12968        let cell = &doc.content[0].content.as_ref().unwrap()[0]
12969            .content
12970            .as_ref()
12971            .unwrap()[0];
12972        let marks = cell.marks.as_ref().expect("should have border mark");
12973        assert_eq!(marks[0].mark_type, "border");
12974        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff0000");
12975        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 1);
12976    }
12977
12978    #[test]
12979    fn media_file_border_mark_roundtrip() {
12980        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}}]}]}]}"##;
12981        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12982        let md = adf_to_markdown(&doc).unwrap();
12983        assert!(
12984            md.contains("border-color=#091e4224"),
12985            "media should have border-color in md: {md}"
12986        );
12987        assert!(
12988            md.contains("border-size=2"),
12989            "media should have border-size in md: {md}"
12990        );
12991        let rt = markdown_to_adf(&md).unwrap();
12992        let media_single = &rt.content[0];
12993        let media = &media_single.content.as_ref().unwrap()[0];
12994        assert_eq!(media.node_type, "media");
12995        let marks = media.marks.as_ref().expect("media should have marks");
12996        assert_eq!(marks.len(), 1);
12997        assert_eq!(marks[0].mark_type, "border");
12998        let attrs = marks[0].attrs.as_ref().unwrap();
12999        assert_eq!(attrs["color"], "#091e4224");
13000        assert_eq!(attrs["size"], 2);
13001    }
13002
13003    #[test]
13004    fn media_external_border_mark_roundtrip() {
13005        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}}]}]}]}"##;
13006        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13007        let md = adf_to_markdown(&doc).unwrap();
13008        assert!(
13009            md.contains("border-color=#ff0000"),
13010            "external media should have border-color in md: {md}"
13011        );
13012        assert!(
13013            md.contains("border-size=3"),
13014            "external media should have border-size in md: {md}"
13015        );
13016        let rt = markdown_to_adf(&md).unwrap();
13017        let media = &rt.content[0].content.as_ref().unwrap()[0];
13018        let marks = media.marks.as_ref().expect("media should have marks");
13019        assert_eq!(marks[0].mark_type, "border");
13020        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff0000");
13021        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
13022    }
13023
13024    #[test]
13025    fn media_file_no_border_mark_unchanged() {
13026        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}}]}]}"#;
13027        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13028        let md = adf_to_markdown(&doc).unwrap();
13029        assert!(
13030            !md.contains("border-color"),
13031            "no border attrs expected: {md}"
13032        );
13033        let rt = markdown_to_adf(&md).unwrap();
13034        let media = &rt.content[0].content.as_ref().unwrap()[0];
13035        assert!(media.marks.is_none(), "no marks expected on plain media");
13036    }
13037
13038    #[test]
13039    fn media_border_size_only_defaults_color() {
13040        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}}]}]}]}"#;
13041        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13042        let md = adf_to_markdown(&doc).unwrap();
13043        assert!(md.contains("border-size=4"), "md: {md}");
13044        let rt = markdown_to_adf(&md).unwrap();
13045        let media = &rt.content[0].content.as_ref().unwrap()[0];
13046        let marks = media.marks.as_ref().expect("should have border mark");
13047        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#000000");
13048        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 4);
13049    }
13050
13051    #[test]
13052    fn media_border_color_only_defaults_size() {
13053        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"}}]}]}]}"##;
13054        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13055        let md = adf_to_markdown(&doc).unwrap();
13056        assert!(md.contains("border-color=#00ff00"), "md: {md}");
13057        let rt = markdown_to_adf(&md).unwrap();
13058        let media = &rt.content[0].content.as_ref().unwrap()[0];
13059        let marks = media.marks.as_ref().expect("should have border mark");
13060        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#00ff00");
13061        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 1);
13062    }
13063
13064    #[test]
13065    fn media_border_with_other_attrs_roundtrip() {
13066        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}}]}]}]}"##;
13067        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13068        let md = adf_to_markdown(&doc).unwrap();
13069        assert!(md.contains("layout=wide"), "md: {md}");
13070        assert!(md.contains("mediaWidth=600"), "md: {md}");
13071        assert!(md.contains("border-color=#091e4224"), "md: {md}");
13072        assert!(md.contains("border-size=2"), "md: {md}");
13073        let rt = markdown_to_adf(&md).unwrap();
13074        let ms = &rt.content[0];
13075        assert_eq!(ms.attrs.as_ref().unwrap()["layout"], "wide");
13076        let media = &ms.content.as_ref().unwrap()[0];
13077        let marks = media.marks.as_ref().expect("should have marks");
13078        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#091e4224");
13079        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 2);
13080    }
13081
13082    #[test]
13083    fn table_row_localid_roundtrip() {
13084        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"}]}]}]}]}]}"#;
13085        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13086        let md = adf_to_markdown(&doc).unwrap();
13087        assert!(
13088            md.contains("localId=tr-001"),
13089            "tableRow should have localId in md: {md}"
13090        );
13091        let rt = markdown_to_adf(&md).unwrap();
13092        let row = &rt.content[0].content.as_ref().unwrap()[0];
13093        assert_eq!(
13094            row.attrs.as_ref().unwrap()["localId"],
13095            "tr-001",
13096            "tableRow localId should round-trip"
13097        );
13098    }
13099
13100    #[test]
13101    fn list_item_localid_roundtrip() {
13102        // listItem localId is emitted as trailing inline attrs and parsed back
13103        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"}]}]}]}]}"#;
13104        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13105        let md = adf_to_markdown(&doc).unwrap();
13106        assert!(
13107            md.contains("localId=li-001"),
13108            "listItem should have localId in md: {md}"
13109        );
13110        // Verify localId is on the listItem, NOT promoted to bulletList
13111        let rt = markdown_to_adf(&md).unwrap();
13112        let list = &rt.content[0];
13113        assert!(
13114            list.attrs.is_none() || list.attrs.as_ref().unwrap().get("localId").is_none(),
13115            "bulletList should NOT have localId: {:?}",
13116            list.attrs
13117        );
13118        let item = &list.content.as_ref().unwrap()[0];
13119        assert_eq!(
13120            item.attrs.as_ref().unwrap()["localId"],
13121            "li-001",
13122            "listItem should have localId=li-001"
13123        );
13124    }
13125
13126    #[test]
13127    fn list_item_localid_not_promoted_to_parent() {
13128        // Verify localId stays on listItem and doesn't leak to parent list
13129        let md = "- item {localId=li-002}\n";
13130        let doc = markdown_to_adf(md).unwrap();
13131        let list = &doc.content[0];
13132        assert!(
13133            list.attrs.is_none(),
13134            "bulletList should have no attrs: {:?}",
13135            list.attrs
13136        );
13137        let item = &list.content.as_ref().unwrap()[0];
13138        assert_eq!(item.attrs.as_ref().unwrap()["localId"], "li-002");
13139    }
13140
13141    #[test]
13142    fn ordered_list_item_localid_roundtrip() {
13143        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"}]}]}]}]}"#;
13144        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13145        let md = adf_to_markdown(&doc).unwrap();
13146        assert!(md.contains("localId=oli-001"), "md: {md}");
13147        let rt = markdown_to_adf(&md).unwrap();
13148        let item = &rt.content[0].content.as_ref().unwrap()[0];
13149        assert_eq!(item.attrs.as_ref().unwrap()["localId"], "oli-001");
13150    }
13151
13152    #[test]
13153    fn task_item_localid_roundtrip() {
13154        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"}]}]}]}]}"#;
13155        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13156        let md = adf_to_markdown(&doc).unwrap();
13157        assert!(md.contains("localId=ti-001"), "md: {md}");
13158        let rt = markdown_to_adf(&md).unwrap();
13159        let item = &rt.content[0].content.as_ref().unwrap()[0];
13160        assert_eq!(item.attrs.as_ref().unwrap()["localId"], "ti-001");
13161    }
13162
13163    /// Issue #447: taskList with empty-string localId and taskItems with
13164    /// short numeric localIds must survive a full round-trip.
13165    #[test]
13166    fn task_list_short_localid_roundtrip() {
13167        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"}]}]}]}"#;
13168        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13169        let md = adf_to_markdown(&doc).unwrap();
13170        // Both taskItem localIds should appear in the markdown
13171        assert!(md.contains("localId=42"), "localId=42 missing: {md}");
13172        assert!(md.contains("localId=99"), "localId=99 missing: {md}");
13173        // Empty-string localId should NOT appear as {localId=}
13174        assert!(
13175            !md.contains("localId=}"),
13176            "empty localId should not be emitted: {md}"
13177        );
13178        let rt = markdown_to_adf(&md).unwrap();
13179        let task_list = &rt.content[0];
13180        assert_eq!(task_list.node_type, "taskList");
13181        // No spurious extra nodes from {localId=}
13182        assert_eq!(rt.content.len(), 1, "should be exactly one top-level node");
13183        let items = task_list.content.as_ref().unwrap();
13184        assert_eq!(items.len(), 2);
13185        // First taskItem: localId=42, state=TODO, no content
13186        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13187        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
13188        assert!(
13189            items[0].content.is_none(),
13190            "empty taskItem should have no content: {:?}",
13191            items[0].content
13192        );
13193        // Second taskItem: localId=99, state=DONE, content with text
13194        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "99");
13195        assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
13196        let content = items[1].content.as_ref().unwrap();
13197        assert_eq!(content.len(), 1);
13198        assert_eq!(content[0].text.as_deref(), Some("done task"));
13199    }
13200
13201    /// Issue #507: numeric localId on taskItem with hardBreak must survive
13202    /// round-trip — the {localId=…} suffix lands on the continuation line
13203    /// and must still be extracted by the parser.
13204    #[test]
13205    fn task_item_numeric_localid_with_hardbreak_roundtrip() {
13206        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!!)"}]}]}]}]}"#;
13207        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13208        let md = adf_to_markdown(&doc).unwrap();
13209        // localId must appear in the markdown output
13210        assert!(md.contains("localId=42"), "localId=42 missing: {md}");
13211        // Round-trip back to ADF
13212        let rt = markdown_to_adf(&md).unwrap();
13213        assert_eq!(rt.content.len(), 1, "exactly one top-level node");
13214        let task_list = &rt.content[0];
13215        assert_eq!(task_list.node_type, "taskList");
13216        let items = task_list.content.as_ref().unwrap();
13217        assert_eq!(items.len(), 1);
13218        // localId preserved
13219        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13220        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DONE");
13221        // Content structure preserved: paragraph with link + hardBreak + text
13222        let para = &items[0].content.as_ref().unwrap()[0];
13223        assert_eq!(para.node_type, "paragraph");
13224        let inlines = para.content.as_ref().unwrap();
13225        assert_eq!(inlines[0].node_type, "text");
13226        assert_eq!(
13227            inlines[0].text.as_deref(),
13228            Some("Engineering Onboarding Link")
13229        );
13230        assert_eq!(inlines[1].node_type, "hardBreak");
13231        assert_eq!(inlines[2].node_type, "text");
13232        assert_eq!(
13233            inlines[2].text.as_deref(),
13234            Some("(This has links to all the various useful tools!!)")
13235        );
13236        // The {localId=…} must not appear as literal text in the ADF output
13237        let rt_json = serde_json::to_string(&rt).unwrap();
13238        assert!(
13239            !rt_json.contains("{localId="),
13240            "localId attr syntax should not leak into ADF text: {rt_json}"
13241        );
13242    }
13243
13244    /// Issue #507: multiple taskItems with hardBreaks and numeric localIds.
13245    #[test]
13246    fn task_item_multiple_hardbreak_localids_roundtrip() {
13247        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"}]}]}]}]}"#;
13248        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13249        let md = adf_to_markdown(&doc).unwrap();
13250        assert!(md.contains("localId=42"), "localId=42 missing: {md}");
13251        assert!(md.contains("localId=67"), "localId=67 missing: {md}");
13252        let rt = markdown_to_adf(&md).unwrap();
13253        let items = rt.content[0].content.as_ref().unwrap();
13254        assert_eq!(items.len(), 2);
13255        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13256        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "67");
13257        // Verify hardBreak content structure for both items
13258        for item in items {
13259            let para = &item.content.as_ref().unwrap()[0];
13260            assert_eq!(para.node_type, "paragraph");
13261            let inlines = para.content.as_ref().unwrap();
13262            assert_eq!(inlines[1].node_type, "hardBreak");
13263        }
13264    }
13265
13266    /// Issue #521: sibling taskItems with numeric localIds and hardBreak —
13267    /// unwrapped inline content.  The hardBreak continuation line must be
13268    /// indented so it stays within the list item, and both localIds must
13269    /// survive the round-trip.
13270    #[test]
13271    fn task_item_sibling_localid_hardbreak_unwrapped_roundtrip() {
13272        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"}]}]}]}"#;
13273        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13274        let md = adf_to_markdown(&doc).unwrap();
13275        // Continuation line must be indented
13276        assert!(
13277            md.contains("  (parenthetical"),
13278            "continuation line should be 2-space indented: {md}"
13279        );
13280        assert!(md.contains("localId=42"), "localId=42 missing: {md}");
13281        assert!(md.contains("localId=69"), "localId=69 missing: {md}");
13282        let rt = markdown_to_adf(&md).unwrap();
13283        // Must remain a single taskList with 2 items
13284        assert_eq!(
13285            rt.content.len(),
13286            1,
13287            "should be one taskList: {:#?}",
13288            rt.content
13289        );
13290        assert_eq!(rt.content[0].node_type, "taskList");
13291        let items = rt.content[0].content.as_ref().unwrap();
13292        assert_eq!(items.len(), 2, "should have 2 taskItems");
13293        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13294        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "69");
13295        // Verify first item has hardBreak
13296        let first_content = items[0].content.as_ref().unwrap();
13297        assert!(
13298            first_content.iter().any(|n| n.node_type == "hardBreak"),
13299            "first item should contain hardBreak"
13300        );
13301        // Verify second item content
13302        let second_content = items[1].content.as_ref().unwrap();
13303        assert_eq!(second_content[0].node_type, "text");
13304        assert_eq!(
13305            second_content[0].text.as_deref().unwrap(),
13306            "second task item"
13307        );
13308    }
13309
13310    /// Issue #521: sibling taskItems with paragraph-wrapped content and
13311    /// hardBreak — localIds must not be swapped or lost.
13312    #[test]
13313    fn task_item_sibling_localid_hardbreak_paragraph_roundtrip() {
13314        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"}]}]}]}]}"#;
13315        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13316        let md = adf_to_markdown(&doc).unwrap();
13317        let rt = markdown_to_adf(&md).unwrap();
13318        assert_eq!(
13319            rt.content.len(),
13320            1,
13321            "should be one taskList: {:#?}",
13322            rt.content
13323        );
13324        let items = rt.content[0].content.as_ref().unwrap();
13325        assert_eq!(items.len(), 2);
13326        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13327        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "69");
13328    }
13329
13330    /// Issue #521: three sibling taskItems — the middle one has a hardBreak.
13331    /// Ensures localIds don't leak between adjacent items.
13332    #[test]
13333    fn task_item_three_siblings_middle_hardbreak_roundtrip() {
13334        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"}]}]}]}"#;
13335        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13336        let md = adf_to_markdown(&doc).unwrap();
13337        let rt = markdown_to_adf(&md).unwrap();
13338        assert_eq!(rt.content.len(), 1);
13339        let items = rt.content[0].content.as_ref().unwrap();
13340        assert_eq!(items.len(), 3);
13341        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "10");
13342        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "20");
13343        assert_eq!(items[2].attrs.as_ref().unwrap()["localId"], "30");
13344        // Middle item should have hardBreak
13345        let mid_content = items[1].content.as_ref().unwrap();
13346        assert!(mid_content.iter().any(|n| n.node_type == "hardBreak"));
13347    }
13348
13349    /// Issue #447: regression — taskList with empty localId must not inject
13350    /// a spurious paragraph.
13351    #[test]
13352    fn task_list_empty_localid_no_spurious_paragraph() {
13353        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"}]}]}]}"#;
13354        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13355        let md = adf_to_markdown(&doc).unwrap();
13356        assert!(
13357            !md.contains("{localId=}"),
13358            "empty localId should not be emitted: {md}"
13359        );
13360        let rt = markdown_to_adf(&md).unwrap();
13361        assert_eq!(
13362            rt.content.len(),
13363            1,
13364            "no spurious paragraph: {:#?}",
13365            rt.content
13366        );
13367        assert_eq!(rt.content[0].node_type, "taskList");
13368    }
13369
13370    /// Issue #447: taskList localId should be stripped when strip_local_ids is set.
13371    #[test]
13372    fn task_list_localid_stripped() {
13373        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"}]}]}]}]}"#;
13374        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13375        let opts = RenderOptions {
13376            strip_local_ids: true,
13377        };
13378        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13379        assert!(!md.contains("localId"), "localId should be stripped: {md}");
13380    }
13381
13382    /// Issue #447: taskItem with no content still emits localId.
13383    #[test]
13384    fn task_item_no_content_emits_localid() {
13385        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"}}]}]}"#;
13386        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13387        let md = adf_to_markdown(&doc).unwrap();
13388        assert!(
13389            md.contains("localId=abc"),
13390            "localId should be emitted even without content: {md}"
13391        );
13392        let rt = markdown_to_adf(&md).unwrap();
13393        let item = &rt.content[0].content.as_ref().unwrap()[0];
13394        assert_eq!(item.attrs.as_ref().unwrap()["localId"], "abc");
13395        assert!(item.content.is_none(), "should have no content");
13396    }
13397
13398    /// Issue #447: taskList localId roundtrips through block attrs.
13399    #[test]
13400    fn task_list_localid_roundtrip() {
13401        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"}]}]}]}]}"#;
13402        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13403        let md = adf_to_markdown(&doc).unwrap();
13404        assert!(
13405            md.contains("localId=tl-xyz"),
13406            "taskList localId missing: {md}"
13407        );
13408        let rt = markdown_to_adf(&md).unwrap();
13409        assert_eq!(
13410            rt.content[0].attrs.as_ref().unwrap()["localId"],
13411            "tl-xyz",
13412            "taskList localId should survive round-trip"
13413        );
13414    }
13415
13416    /// Issue #478: taskItem with paragraph wrapper (no localId) preserves wrapper on round-trip.
13417    #[test]
13418    fn task_item_paragraph_wrapper_roundtrip_no_localid() {
13419        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"}]}]}]}]}"#;
13420        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13421        let md = adf_to_markdown(&doc).unwrap();
13422        assert!(
13423            md.contains("paraLocalId=_"),
13424            "should emit paraLocalId=_ sentinel: {md}"
13425        );
13426        let rt = markdown_to_adf(&md).unwrap();
13427        let item = &rt.content[0].content.as_ref().unwrap()[0];
13428        let content = item.content.as_ref().unwrap();
13429        assert_eq!(content.len(), 1, "should have one child: {content:#?}");
13430        assert_eq!(
13431            content[0].node_type, "paragraph",
13432            "child should be a paragraph: {content:#?}"
13433        );
13434        let para_content = content[0].content.as_ref().unwrap();
13435        assert_eq!(
13436            para_content[0].text.as_deref(),
13437            Some("A task with paragraph wrapper")
13438        );
13439        // Paragraph should have no attrs (localId was absent in the original)
13440        assert!(
13441            content[0].attrs.is_none(),
13442            "paragraph should have no attrs: {:?}",
13443            content[0].attrs
13444        );
13445    }
13446
13447    /// Issue #478: taskItem with paragraph wrapper AND paraLocalId preserves both.
13448    #[test]
13449    fn task_item_paragraph_wrapper_roundtrip_with_localid() {
13450        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"}]}]}]}]}"#;
13451        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13452        let md = adf_to_markdown(&doc).unwrap();
13453        assert!(
13454            md.contains("paraLocalId=p-001"),
13455            "should emit paraLocalId=p-001: {md}"
13456        );
13457        let rt = markdown_to_adf(&md).unwrap();
13458        let item = &rt.content[0].content.as_ref().unwrap()[0];
13459        let content = item.content.as_ref().unwrap();
13460        assert_eq!(content[0].node_type, "paragraph");
13461        assert_eq!(
13462            content[0].attrs.as_ref().unwrap()["localId"],
13463            "p-001",
13464            "paragraph localId should be preserved"
13465        );
13466    }
13467
13468    /// Issue #478: taskItem WITHOUT paragraph wrapper (unwrapped inline) still round-trips correctly.
13469    #[test]
13470    fn task_item_unwrapped_inline_no_paragraph_on_roundtrip() {
13471        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"}]}]}]}"#;
13472        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13473        let md = adf_to_markdown(&doc).unwrap();
13474        assert!(
13475            !md.contains("paraLocalId"),
13476            "should NOT emit paraLocalId for unwrapped inline: {md}"
13477        );
13478        let rt = markdown_to_adf(&md).unwrap();
13479        let item = &rt.content[0].content.as_ref().unwrap()[0];
13480        let content = item.content.as_ref().unwrap();
13481        assert_eq!(
13482            content[0].node_type, "text",
13483            "should remain unwrapped: {content:#?}"
13484        );
13485    }
13486
13487    /// Issue #478: DONE taskItem with paragraph wrapper round-trips.
13488    #[test]
13489    fn task_item_done_paragraph_wrapper_roundtrip() {
13490        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"}]}]}]}]}"#;
13491        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13492        let md = adf_to_markdown(&doc).unwrap();
13493        assert!(md.contains("- [x]"), "should render as done: {md}");
13494        let rt = markdown_to_adf(&md).unwrap();
13495        let item = &rt.content[0].content.as_ref().unwrap()[0];
13496        assert_eq!(item.attrs.as_ref().unwrap()["state"], "DONE");
13497        let content = item.content.as_ref().unwrap();
13498        assert_eq!(content[0].node_type, "paragraph");
13499    }
13500
13501    /// Issue #478: mixed taskItems — some with paragraph wrapper, some without.
13502    #[test]
13503    fn task_item_mixed_paragraph_and_unwrapped_roundtrip() {
13504        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"}]}]}]}"#;
13505        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13506        let md = adf_to_markdown(&doc).unwrap();
13507        let rt = markdown_to_adf(&md).unwrap();
13508        let items = rt.content[0].content.as_ref().unwrap();
13509        assert_eq!(items.len(), 2);
13510        // First item: paragraph wrapper preserved
13511        let c1 = items[0].content.as_ref().unwrap();
13512        assert_eq!(
13513            c1[0].node_type, "paragraph",
13514            "first item should have paragraph wrapper"
13515        );
13516        // Second item: no paragraph wrapper
13517        let c2 = items[1].content.as_ref().unwrap();
13518        assert_eq!(
13519            c2[0].node_type, "text",
13520            "second item should remain unwrapped"
13521        );
13522    }
13523
13524    /// Issue #478: taskItem with paragraph wrapper containing marks round-trips.
13525    #[test]
13526    fn task_item_paragraph_wrapper_with_marks_roundtrip() {
13527        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"}]}]}]}]}]}"#;
13528        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13529        let md = adf_to_markdown(&doc).unwrap();
13530        let rt = markdown_to_adf(&md).unwrap();
13531        let item = &rt.content[0].content.as_ref().unwrap()[0];
13532        let content = item.content.as_ref().unwrap();
13533        assert_eq!(content[0].node_type, "paragraph");
13534        let para_children = content[0].content.as_ref().unwrap();
13535        assert!(
13536            para_children.len() >= 2,
13537            "paragraph should contain multiple inline nodes"
13538        );
13539    }
13540
13541    /// Issue #478: strip_local_ids suppresses the paraLocalId=_ sentinel too.
13542    #[test]
13543    fn task_item_paragraph_wrapper_stripped_with_option() {
13544        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"}]}]}]}]}"#;
13545        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13546        let opts = RenderOptions {
13547            strip_local_ids: true,
13548        };
13549        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13550        assert!(
13551            !md.contains("paraLocalId"),
13552            "paraLocalId should be stripped: {md}"
13553        );
13554        assert!(
13555            !md.contains("localId"),
13556            "all localIds should be stripped: {md}"
13557        );
13558    }
13559
13560    #[test]
13561    fn trailing_space_preserved_with_hex_localid() {
13562        // Issue #449: trailing whitespace stripped from text node
13563        // when listItem has a hex-format localId (no hyphens)
13564        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 "}]}]}]}]}"#;
13565        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13566        let md = adf_to_markdown(&doc).unwrap();
13567        let rt = markdown_to_adf(&md).unwrap();
13568        let item = &rt.content[0].content.as_ref().unwrap()[0];
13569        assert_eq!(
13570            item.attrs.as_ref().unwrap()["localId"],
13571            "aabb112233cc",
13572            "localId should round-trip"
13573        );
13574        let para = &item.content.as_ref().unwrap()[0];
13575        let inlines = para.content.as_ref().unwrap();
13576        let last = inlines.last().unwrap();
13577        assert!(
13578            last.text.as_deref().unwrap_or("").ends_with(' '),
13579            "trailing space should be preserved, got nodes: {:?}",
13580            inlines
13581                .iter()
13582                .map(|n| (&n.node_type, &n.text))
13583                .collect::<Vec<_>>()
13584        );
13585    }
13586
13587    #[test]
13588    fn extract_trailing_local_id_preserves_trailing_space() {
13589        // Issue #449: only strip the single separator space before {localId=...}
13590        let (before, lid, _) = extract_trailing_local_id("trailing space  {localId=aabb112233cc}");
13591        assert_eq!(before, "trailing space ");
13592        assert_eq!(lid.as_deref(), Some("aabb112233cc"));
13593    }
13594
13595    #[test]
13596    fn extract_trailing_local_id_no_trailing_space() {
13597        let (before, lid, _) = extract_trailing_local_id("text {localId=abc123}");
13598        assert_eq!(before, "text");
13599        assert_eq!(lid.as_deref(), Some("abc123"));
13600    }
13601
13602    #[test]
13603    fn extract_trailing_local_id_no_attrs() {
13604        let (before, lid, pid) = extract_trailing_local_id("plain text");
13605        assert_eq!(before, "plain text");
13606        assert!(lid.is_none());
13607        assert!(pid.is_none());
13608    }
13609
13610    #[test]
13611    fn list_item_localid_stripped() {
13612        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"}]}]}]}]}"#;
13613        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13614        let opts = RenderOptions {
13615            strip_local_ids: true,
13616        };
13617        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13618        assert!(!md.contains("localId"), "localId should be stripped: {md}");
13619    }
13620
13621    #[test]
13622    fn paragraph_localid_in_list_item_roundtrip() {
13623        // Issue #417: paragraph.attrs.localId dropped in listItem context
13624        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"}]}]}]}]}"#;
13625        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13626        let md = adf_to_markdown(&doc).unwrap();
13627        assert!(
13628            md.contains("paraLocalId=para-001"),
13629            "paragraph localId should be in md: {md}"
13630        );
13631        let rt = markdown_to_adf(&md).unwrap();
13632        let item = &rt.content[0].content.as_ref().unwrap()[0];
13633        assert_eq!(
13634            item.attrs.as_ref().unwrap()["localId"],
13635            "item-001",
13636            "listItem localId should survive"
13637        );
13638        let para = &item.content.as_ref().unwrap()[0];
13639        assert_eq!(
13640            para.attrs.as_ref().unwrap()["localId"],
13641            "para-001",
13642            "paragraph localId should survive round-trip"
13643        );
13644    }
13645
13646    #[test]
13647    fn paragraph_localid_in_ordered_list_item_roundtrip() {
13648        // Issue #417: paragraph localId in ordered list
13649        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"}]}]}]}]}"#;
13650        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13651        let md = adf_to_markdown(&doc).unwrap();
13652        assert!(md.contains("paraLocalId=op-001"), "md: {md}");
13653        let rt = markdown_to_adf(&md).unwrap();
13654        let item = &rt.content[0].content.as_ref().unwrap()[0];
13655        assert_eq!(item.attrs.as_ref().unwrap()["localId"], "oli-001");
13656        let para = &item.content.as_ref().unwrap()[0];
13657        assert_eq!(para.attrs.as_ref().unwrap()["localId"], "op-001");
13658    }
13659
13660    #[test]
13661    fn paragraph_localid_only_in_list_item() {
13662        // paragraph has localId but listItem does not
13663        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"}]}]}]}]}"#;
13664        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13665        let md = adf_to_markdown(&doc).unwrap();
13666        assert!(
13667            md.contains("paraLocalId=para-only"),
13668            "paragraph localId should be emitted: {md}"
13669        );
13670        let rt = markdown_to_adf(&md).unwrap();
13671        let item = &rt.content[0].content.as_ref().unwrap()[0];
13672        assert!(item.attrs.is_none(), "listItem should have no attrs");
13673        let para = &item.content.as_ref().unwrap()[0];
13674        assert_eq!(para.attrs.as_ref().unwrap()["localId"], "para-only");
13675    }
13676
13677    #[test]
13678    fn paragraph_localid_in_table_header_roundtrip() {
13679        // Issue #417: paragraph.attrs.localId dropped in tableHeader context
13680        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"}]}]}]}]}]}"#;
13681        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13682        let md = adf_to_markdown(&doc).unwrap();
13683        // Should use directive form (not pipe table) to preserve paragraph localId
13684        assert!(
13685            md.contains("localId=aaaa-aaaa"),
13686            "paragraph localId should be in md: {md}"
13687        );
13688        let rt = markdown_to_adf(&md).unwrap();
13689        let cell = &rt.content[0].content.as_ref().unwrap()[0]
13690            .content
13691            .as_ref()
13692            .unwrap()[0];
13693        let para = &cell.content.as_ref().unwrap()[0];
13694        assert_eq!(
13695            para.attrs.as_ref().unwrap()["localId"],
13696            "aaaa-aaaa",
13697            "paragraph localId should survive round-trip in tableHeader"
13698        );
13699    }
13700
13701    #[test]
13702    fn paragraph_localid_in_table_cell_roundtrip() {
13703        // Issue #417: paragraph localId in tableCell forces directive table
13704        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"}]}]}]}]}]}"#;
13705        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13706        let md = adf_to_markdown(&doc).unwrap();
13707        assert!(
13708            md.contains("localId=cell-para"),
13709            "paragraph localId should be in md: {md}"
13710        );
13711        let rt = markdown_to_adf(&md).unwrap();
13712        // Data row -> cell -> paragraph
13713        let cell = &rt.content[0].content.as_ref().unwrap()[1]
13714            .content
13715            .as_ref()
13716            .unwrap()[0];
13717        let para = &cell.content.as_ref().unwrap()[0];
13718        assert_eq!(
13719            para.attrs.as_ref().unwrap()["localId"],
13720            "cell-para",
13721            "paragraph localId should survive round-trip in tableCell"
13722        );
13723    }
13724
13725    #[test]
13726    fn nbsp_paragraph_with_localid_roundtrip() {
13727        // Issue #417: nbsp paragraph localId emitted as text instead of attrs
13728        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"nbsp-para"},"content":[{"type":"text","text":"\u00a0"}]}]}"#;
13729        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13730        let md = adf_to_markdown(&doc).unwrap();
13731        assert!(
13732            md.contains("::paragraph["),
13733            "nbsp should use directive form: {md}"
13734        );
13735        assert!(
13736            md.contains("localId=nbsp-para"),
13737            "localId should be in directive: {md}"
13738        );
13739        let rt = markdown_to_adf(&md).unwrap();
13740        let para = &rt.content[0];
13741        assert_eq!(
13742            para.attrs.as_ref().unwrap()["localId"],
13743            "nbsp-para",
13744            "localId should survive round-trip"
13745        );
13746        let text = para.content.as_ref().unwrap()[0].text.as_ref().unwrap();
13747        assert_eq!(text, "\u{00a0}", "nbsp should survive");
13748    }
13749
13750    #[test]
13751    fn empty_paragraph_with_localid_roundtrip() {
13752        // Empty paragraph directive with localId
13753        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"empty-para"}}]}"#;
13754        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13755        let md = adf_to_markdown(&doc).unwrap();
13756        assert!(
13757            md.contains("::paragraph{localId=empty-para}"),
13758            "empty paragraph should include localId in directive: {md}"
13759        );
13760        let rt = markdown_to_adf(&md).unwrap();
13761        assert_eq!(
13762            rt.content[0].attrs.as_ref().unwrap()["localId"],
13763            "empty-para"
13764        );
13765    }
13766
13767    #[test]
13768    fn paragraph_localid_stripped_from_list_item() {
13769        // strip_local_ids should also strip paraLocalId
13770        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"}]}]}]}]}"#;
13771        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13772        let opts = RenderOptions {
13773            strip_local_ids: true,
13774        };
13775        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13776        assert!(!md.contains("localId"), "localId should be stripped: {md}");
13777        assert!(
13778            !md.contains("paraLocalId"),
13779            "paraLocalId should be stripped: {md}"
13780        );
13781    }
13782
13783    #[test]
13784    fn date_directive() {
13785        let doc = markdown_to_adf("Due by :date[2026-04-15].").unwrap();
13786        let content = doc.content[0].content.as_ref().unwrap();
13787        assert_eq!(content[1].node_type, "date");
13788        // ISO date is converted to epoch milliseconds
13789        assert_eq!(
13790            content[1].attrs.as_ref().unwrap()["timestamp"],
13791            "1776211200000"
13792        );
13793    }
13794
13795    #[test]
13796    fn adf_date_to_markdown() {
13797        // ADF dates use epoch ms; renderer converts back to ISO with timestamp attr
13798        let doc = AdfDocument {
13799            version: 1,
13800            doc_type: "doc".to_string(),
13801            content: vec![AdfNode::paragraph(vec![AdfNode::date("1776211200000")])],
13802        };
13803        let md = adf_to_markdown(&doc).unwrap();
13804        assert!(md.contains(":date[2026-04-15]{timestamp=1776211200000}"));
13805    }
13806
13807    #[test]
13808    fn adf_date_iso_passthrough() {
13809        // If ADF already has ISO date (legacy), pass through
13810        let doc = AdfDocument {
13811            version: 1,
13812            doc_type: "doc".to_string(),
13813            content: vec![AdfNode::paragraph(vec![AdfNode::date("2026-04-15")])],
13814        };
13815        let md = adf_to_markdown(&doc).unwrap();
13816        assert!(md.contains(":date[2026-04-15]{timestamp=2026-04-15}"));
13817    }
13818
13819    #[test]
13820    fn round_trip_date() {
13821        let md = "Due by :date[2026-04-15].\n";
13822        let doc = markdown_to_adf(md).unwrap();
13823        let result = adf_to_markdown(&doc).unwrap();
13824        assert!(result.contains(":date[2026-04-15]"));
13825    }
13826
13827    #[test]
13828    fn round_trip_date_non_midnight_timestamp() {
13829        // Issue #409: non-midnight timestamps must survive round-trip
13830        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000"}}]}]}"#;
13831        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13832        let md = adf_to_markdown(&doc).unwrap();
13833        // JFM should include the original timestamp
13834        assert!(
13835            md.contains("timestamp=1700000000000"),
13836            "JFM should preserve original timestamp: {md}"
13837        );
13838        // Round-trip back to ADF
13839        let doc2 = markdown_to_adf(&md).unwrap();
13840        let content = doc2.content[0].content.as_ref().unwrap();
13841        assert_eq!(
13842            content[0].attrs.as_ref().unwrap()["timestamp"],
13843            "1700000000000",
13844            "Round-trip must preserve original non-midnight timestamp"
13845        );
13846    }
13847
13848    #[test]
13849    fn date_epoch_ms_passthrough() {
13850        // If JFM date is already epoch ms, pass through
13851        let doc = markdown_to_adf("Due by :date[1776211200000].").unwrap();
13852        let content = doc.content[0].content.as_ref().unwrap();
13853        assert_eq!(
13854            content[1].attrs.as_ref().unwrap()["timestamp"],
13855            "1776211200000"
13856        );
13857    }
13858
13859    #[test]
13860    fn date_timestamp_attr_preferred_over_content() {
13861        // When timestamp attr is present, it takes priority over the display date
13862        let md = ":date[2023-11-14]{timestamp=1700000000000}\n";
13863        let doc = markdown_to_adf(md).unwrap();
13864        let content = doc.content[0].content.as_ref().unwrap();
13865        assert_eq!(
13866            content[0].attrs.as_ref().unwrap()["timestamp"],
13867            "1700000000000",
13868            "timestamp attr should be used directly"
13869        );
13870    }
13871
13872    #[test]
13873    fn date_without_timestamp_attr_backward_compat() {
13874        // Legacy JFM without timestamp attr still works via iso_date_to_epoch_ms
13875        let md = ":date[2026-04-15]\n";
13876        let doc = markdown_to_adf(md).unwrap();
13877        let content = doc.content[0].content.as_ref().unwrap();
13878        assert_eq!(
13879            content[0].attrs.as_ref().unwrap()["timestamp"],
13880            "1776211200000",
13881            "Should fall back to computing timestamp from date string"
13882        );
13883    }
13884
13885    #[test]
13886    fn date_with_local_id_and_timestamp() {
13887        // Both localId and timestamp should round-trip
13888        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
13889        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13890        let md = adf_to_markdown(&doc).unwrap();
13891        assert!(
13892            md.contains("timestamp=1700000000000"),
13893            "Should contain timestamp: {md}"
13894        );
13895        assert!(md.contains("localId=d-001"), "Should contain localId: {md}");
13896        // Round-trip
13897        let doc2 = markdown_to_adf(&md).unwrap();
13898        let content = doc2.content[0].content.as_ref().unwrap();
13899        let attrs = content[0].attrs.as_ref().unwrap();
13900        assert_eq!(attrs["timestamp"], "1700000000000");
13901        assert_eq!(attrs["localId"], "d-001");
13902    }
13903
13904    #[test]
13905    fn mention_directive() {
13906        let doc = markdown_to_adf("Assigned to :mention[Alice]{id=abc123}.").unwrap();
13907        let content = doc.content[0].content.as_ref().unwrap();
13908        assert_eq!(content[1].node_type, "mention");
13909        assert_eq!(content[1].attrs.as_ref().unwrap()["id"], "abc123");
13910        assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "Alice");
13911    }
13912
13913    #[test]
13914    fn adf_mention_to_markdown() {
13915        let doc = AdfDocument {
13916            version: 1,
13917            doc_type: "doc".to_string(),
13918            content: vec![AdfNode::paragraph(vec![AdfNode::mention(
13919                "abc123", "Alice",
13920            )])],
13921        };
13922        let md = adf_to_markdown(&doc).unwrap();
13923        assert!(md.contains(":mention[Alice]{id=abc123}"));
13924    }
13925
13926    #[test]
13927    fn round_trip_mention() {
13928        let md = "Assigned to :mention[Alice]{id=abc123}.\n";
13929        let doc = markdown_to_adf(md).unwrap();
13930        let result = adf_to_markdown(&doc).unwrap();
13931        assert!(result.contains(":mention[Alice]{id=abc123}"));
13932    }
13933
13934    #[test]
13935    fn mention_with_empty_access_level_round_trips() {
13936        // Issue #363: accessLevel="" produces accessLevel= which failed to parse
13937        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13938          {"type":"mention","attrs":{"id":"61921b41c15977006af2b1d1","text":"@Javier Inchausti","accessLevel":""}}
13939        ]}]}"#;
13940        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13941
13942        let md = adf_to_markdown(&doc).unwrap();
13943        let round_tripped = markdown_to_adf(&md).unwrap();
13944        let mention = &round_tripped.content[0].content.as_ref().unwrap()[0];
13945        assert_eq!(
13946            mention.node_type, "mention",
13947            "mention with empty accessLevel was not parsed as mention, got: {}",
13948            mention.node_type
13949        );
13950    }
13951
13952    #[test]
13953    fn span_with_color() {
13954        let doc = markdown_to_adf("This is :span[red text]{color=#ff5630}.").unwrap();
13955        let content = doc.content[0].content.as_ref().unwrap();
13956        assert_eq!(content[1].node_type, "text");
13957        assert_eq!(content[1].text.as_deref(), Some("red text"));
13958        let marks = content[1].marks.as_ref().unwrap();
13959        assert_eq!(marks[0].mark_type, "textColor");
13960    }
13961
13962    #[test]
13963    fn adf_text_color_to_markdown() {
13964        let doc = AdfDocument {
13965            version: 1,
13966            doc_type: "doc".to_string(),
13967            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
13968                "red text",
13969                vec![AdfMark::text_color("#ff5630")],
13970            )])],
13971        };
13972        let md = adf_to_markdown(&doc).unwrap();
13973        assert!(md.contains(":span[red text]{color=#ff5630}"));
13974    }
13975
13976    #[test]
13977    fn round_trip_span_color() {
13978        let md = "This is :span[red text]{color=#ff5630}.\n";
13979        let doc = markdown_to_adf(md).unwrap();
13980        let result = adf_to_markdown(&doc).unwrap();
13981        assert!(result.contains(":span[red text]{color=#ff5630}"));
13982    }
13983
13984    #[test]
13985    fn text_color_and_link_marks_both_preserved() {
13986        // Issue #405: text with both textColor and link marks loses link on round-trip
13987        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13988          {"type":"text","text":"red link","marks":[
13989            {"type":"link","attrs":{"href":"https://example.com"}},
13990            {"type":"textColor","attrs":{"color":"#ff0000"}}
13991          ]}
13992        ]}]}"##;
13993        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13994        let md = adf_to_markdown(&doc).unwrap();
13995        assert!(
13996            md.contains(":span[red link]{color=#ff0000}"),
13997            "JFM should contain span with color, got: {md}"
13998        );
13999        assert!(
14000            md.contains("](https://example.com)"),
14001            "JFM should contain link href, got: {md}"
14002        );
14003        // Full round-trip: both marks survive
14004        let rt = markdown_to_adf(&md).unwrap();
14005        let text_node = &rt.content[0].content.as_ref().unwrap()[0];
14006        let marks = text_node.marks.as_ref().expect("should have marks");
14007        assert!(
14008            marks.iter().any(|m| m.mark_type == "textColor"),
14009            "should have textColor mark, got: {:?}",
14010            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
14011        );
14012        assert!(
14013            marks.iter().any(|m| m.mark_type == "link"),
14014            "should have link mark, got: {:?}",
14015            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
14016        );
14017        // Verify attribute values survive
14018        let link_mark = marks.iter().find(|m| m.mark_type == "link").unwrap();
14019        assert_eq!(
14020            link_mark.attrs.as_ref().unwrap()["href"],
14021            "https://example.com"
14022        );
14023        let color_mark = marks.iter().find(|m| m.mark_type == "textColor").unwrap();
14024        assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#ff0000");
14025    }
14026
14027    #[test]
14028    fn bg_color_and_link_marks_both_preserved() {
14029        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
14030          {"type":"text","text":"highlighted link","marks":[
14031            {"type":"link","attrs":{"href":"https://example.com"}},
14032            {"type":"backgroundColor","attrs":{"color":"#ffff00"}}
14033          ]}
14034        ]}]}"##;
14035        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14036        let md = adf_to_markdown(&doc).unwrap();
14037        assert!(md.contains("bg=#ffff00"), "should have bg color: {md}");
14038        assert!(
14039            md.contains("](https://example.com)"),
14040            "should have link: {md}"
14041        );
14042        let rt = markdown_to_adf(&md).unwrap();
14043        let text_node = &rt.content[0].content.as_ref().unwrap()[0];
14044        let marks = text_node.marks.as_ref().expect("should have marks");
14045        assert!(marks.iter().any(|m| m.mark_type == "backgroundColor"));
14046        assert!(marks.iter().any(|m| m.mark_type == "link"));
14047    }
14048
14049    #[test]
14050    fn text_color_link_and_strong_rendering() {
14051        // Verify textColor + link + strong renders all three formatting elements
14052        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
14053          {"type":"text","text":"bold red link","marks":[
14054            {"type":"strong"},
14055            {"type":"link","attrs":{"href":"https://example.com"}},
14056            {"type":"textColor","attrs":{"color":"#ff0000"}}
14057          ]}
14058        ]}]}"##;
14059        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14060        let md = adf_to_markdown(&doc).unwrap();
14061        assert!(
14062            md.starts_with("**") && md.trim().ends_with("**"),
14063            "should have bold wrapping: {md}"
14064        );
14065        assert!(md.contains("color=#ff0000"), "should have color: {md}");
14066        assert!(
14067            md.contains("](https://example.com)"),
14068            "should have link: {md}"
14069        );
14070    }
14071
14072    #[test]
14073    fn subsup_and_link_marks_both_preserved() {
14074        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
14075          {"type":"text","text":"note","marks":[
14076            {"type":"link","attrs":{"href":"https://example.com"}},
14077            {"type":"subsup","attrs":{"type":"sup"}}
14078          ]}
14079        ]}]}"#;
14080        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14081        let md = adf_to_markdown(&doc).unwrap();
14082        assert!(md.contains("sup"), "should have sup: {md}");
14083        assert!(
14084            md.contains("](https://example.com)"),
14085            "should have link: {md}"
14086        );
14087        let rt = markdown_to_adf(&md).unwrap();
14088        let text_node = &rt.content[0].content.as_ref().unwrap()[0];
14089        let marks = text_node.marks.as_ref().expect("should have marks");
14090        assert!(marks.iter().any(|m| m.mark_type == "subsup"));
14091        assert!(marks.iter().any(|m| m.mark_type == "link"));
14092    }
14093
14094    #[test]
14095    fn text_color_without_link_unchanged() {
14096        // Regression guard: textColor without link should still work
14097        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
14098          {"type":"text","text":"just red","marks":[
14099            {"type":"textColor","attrs":{"color":"#ff0000"}}
14100          ]}
14101        ]}]}"##;
14102        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14103        let md = adf_to_markdown(&doc).unwrap();
14104        assert!(md.contains(":span[just red]{color=#ff0000}"), "md: {md}");
14105        assert!(!md.contains("](http"), "should NOT have link syntax: {md}");
14106    }
14107
14108    #[test]
14109    fn inline_extension_directive() {
14110        let doc =
14111            markdown_to_adf("See :extension[fallback]{type=com.app key=widget} here.").unwrap();
14112        let content = doc.content[0].content.as_ref().unwrap();
14113        assert_eq!(content[1].node_type, "inlineExtension");
14114        assert_eq!(
14115            content[1].attrs.as_ref().unwrap()["extensionType"],
14116            "com.app"
14117        );
14118        assert_eq!(content[1].attrs.as_ref().unwrap()["extensionKey"], "widget");
14119    }
14120
14121    #[test]
14122    fn adf_inline_extension_to_markdown() {
14123        let doc = AdfDocument {
14124            version: 1,
14125            doc_type: "doc".to_string(),
14126            content: vec![AdfNode::paragraph(vec![AdfNode::inline_extension(
14127                "com.app",
14128                "widget",
14129                Some("fallback"),
14130            )])],
14131        };
14132        let md = adf_to_markdown(&doc).unwrap();
14133        assert!(md.contains(":extension[fallback]{type=com.app key=widget}"));
14134    }
14135
14136    // ── Helper function tests ──────────────────────────────────────────
14137
14138    #[test]
14139    fn parse_ordered_list_marker_valid() {
14140        let result = parse_ordered_list_marker("1. Hello");
14141        assert_eq!(result, Some((1, "Hello")));
14142    }
14143
14144    #[test]
14145    fn parse_ordered_list_marker_high_number() {
14146        let result = parse_ordered_list_marker("42. Item");
14147        assert_eq!(result, Some((42, "Item")));
14148    }
14149
14150    #[test]
14151    fn parse_ordered_list_marker_not_a_list() {
14152        assert!(parse_ordered_list_marker("not a list").is_none());
14153        assert!(parse_ordered_list_marker("1.no space").is_none());
14154    }
14155
14156    #[test]
14157    fn is_list_start_various() {
14158        assert!(is_list_start("- item"));
14159        assert!(is_list_start("* item"));
14160        assert!(is_list_start("+ item"));
14161        assert!(is_list_start("1. item"));
14162        assert!(!is_list_start("not a list"));
14163    }
14164
14165    #[test]
14166    fn is_horizontal_rule_various() {
14167        assert!(is_horizontal_rule("---"));
14168        assert!(is_horizontal_rule("***"));
14169        assert!(is_horizontal_rule("___"));
14170        assert!(is_horizontal_rule("------"));
14171        assert!(!is_horizontal_rule("--"));
14172        assert!(!is_horizontal_rule("abc"));
14173    }
14174
14175    #[test]
14176    fn is_table_separator_valid() {
14177        assert!(is_table_separator("| --- | --- |"));
14178        assert!(is_table_separator("|:---:|:---|"));
14179        assert!(!is_table_separator("no pipes here"));
14180    }
14181
14182    #[test]
14183    fn parse_table_row_cells() {
14184        let cells = parse_table_row("| A | B | C |");
14185        assert_eq!(cells, vec!["A", "B", "C"]);
14186    }
14187
14188    #[test]
14189    fn parse_table_row_escaped_pipe_in_cell() {
14190        // Issue #579: `\|` inside a cell is a literal pipe, not a column separator.
14191        let cells = parse_table_row(r"| a\|b | c |");
14192        assert_eq!(cells, vec!["a|b", "c"]);
14193    }
14194
14195    #[test]
14196    fn parse_table_row_escaped_pipe_in_code_span() {
14197        // Issue #579: `\|` inside an inline code span is unescaped at the row level.
14198        let cells = parse_table_row(r"| `parser.decode[T\|json]` | other |");
14199        assert_eq!(cells, vec!["`parser.decode[T|json]`", "other"]);
14200    }
14201
14202    #[test]
14203    fn parse_table_row_preserves_other_backslashes() {
14204        // Only `\|` is special at the row-splitting level; other backslashes pass through.
14205        let cells = parse_table_row(r"| a\\b | c\*d |");
14206        assert_eq!(cells, vec![r"a\\b", r"c\*d"]);
14207    }
14208
14209    #[test]
14210    fn parse_image_syntax_valid() {
14211        let result = parse_image_syntax("![alt](url)");
14212        assert_eq!(result, Some(("alt", "url")));
14213    }
14214
14215    #[test]
14216    fn parse_image_syntax_not_image() {
14217        assert!(parse_image_syntax("not an image").is_none());
14218    }
14219
14220    // ── find_closing_paren tests ────────────────────────────────────
14221
14222    #[test]
14223    fn find_closing_paren_simple() {
14224        assert_eq!(find_closing_paren("(hello)", 0), Some(6));
14225    }
14226
14227    #[test]
14228    fn find_closing_paren_nested() {
14229        assert_eq!(find_closing_paren("(a(b)c)", 0), Some(6));
14230    }
14231
14232    #[test]
14233    fn find_closing_paren_unmatched() {
14234        assert_eq!(find_closing_paren("(no close", 0), None);
14235    }
14236
14237    #[test]
14238    fn find_closing_paren_offset() {
14239        // Start scanning from the second '('
14240        assert_eq!(find_closing_paren("xx(inner)", 2), Some(8));
14241    }
14242
14243    // ── Parentheses-in-URL tests (issue #509) ──────────────────────
14244
14245    #[test]
14246    fn try_parse_link_url_with_parens() {
14247        let input = "[here](https://example.com/faq#access-(permissions)-rest)";
14248        let result = try_parse_link(input, 0);
14249        assert_eq!(
14250            result,
14251            Some((
14252                input.len(),
14253                "here",
14254                "https://example.com/faq#access-(permissions)-rest"
14255            ))
14256        );
14257    }
14258
14259    #[test]
14260    fn try_parse_link_url_no_parens() {
14261        let input = "[text](https://example.com)";
14262        let result = try_parse_link(input, 0);
14263        assert_eq!(result, Some((input.len(), "text", "https://example.com")));
14264    }
14265
14266    #[test]
14267    fn try_parse_link_url_with_multiple_nested_parens() {
14268        let input = "[x](http://en.wikipedia.org/wiki/Foo_(bar_(baz)))";
14269        let result = try_parse_link(input, 0);
14270        assert_eq!(
14271            result,
14272            Some((
14273                input.len(),
14274                "x",
14275                "http://en.wikipedia.org/wiki/Foo_(bar_(baz))"
14276            ))
14277        );
14278    }
14279
14280    #[test]
14281    fn parse_image_syntax_url_with_parens() {
14282        let result = parse_image_syntax("![alt](https://example.com/page_(1))");
14283        assert_eq!(result, Some(("alt", "https://example.com/page_(1)")));
14284    }
14285
14286    #[test]
14287    fn parse_image_syntax_url_no_parens() {
14288        let result = parse_image_syntax("![alt](https://example.com)");
14289        assert_eq!(result, Some(("alt", "https://example.com")));
14290    }
14291
14292    #[test]
14293    fn link_with_parens_round_trip() {
14294        let href = "https://example.com/faq#I-need-access-(permissions)-added-in-Monitor";
14295        let mut text_node = AdfNode::text("here");
14296        text_node.marks = Some(vec![AdfMark::link(href)]);
14297        let adf_input = AdfDocument {
14298            version: 1,
14299            doc_type: "doc".to_string(),
14300            content: vec![AdfNode::paragraph(vec![text_node])],
14301        };
14302
14303        let jfm = adf_to_markdown(&adf_input).unwrap();
14304        let adf_output = markdown_to_adf(&jfm).unwrap();
14305
14306        // Extract the href from the round-tripped ADF
14307        let para = &adf_output.content[0];
14308        let text_node = &para.content.as_ref().unwrap()[0];
14309        let mark = &text_node.marks.as_ref().unwrap()[0];
14310        let result_href = mark.attrs.as_ref().unwrap()["href"].as_str().unwrap();
14311
14312        assert_eq!(result_href, href);
14313    }
14314
14315    #[test]
14316    fn flush_plain_empty_range() {
14317        let mut nodes = Vec::new();
14318        flush_plain("hello", 3, 3, &mut nodes);
14319        assert!(nodes.is_empty());
14320    }
14321
14322    #[test]
14323    fn add_mark_to_unmarked_node() {
14324        let mut node = AdfNode::text("test");
14325        add_mark(&mut node, AdfMark::strong());
14326        assert_eq!(node.marks.as_ref().unwrap().len(), 1);
14327    }
14328
14329    #[test]
14330    fn add_mark_to_marked_node() {
14331        let mut node = AdfNode::text_with_marks("test", vec![AdfMark::strong()]);
14332        add_mark(&mut node, AdfMark::em());
14333        assert_eq!(node.marks.as_ref().unwrap().len(), 2);
14334    }
14335
14336    // ── Directive table tests ──────────────────────────────────────
14337
14338    #[test]
14339    fn directive_table_basic() {
14340        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";
14341        let doc = markdown_to_adf(md).unwrap();
14342        assert_eq!(doc.content[0].node_type, "table");
14343        let rows = doc.content[0].content.as_ref().unwrap();
14344        assert_eq!(rows.len(), 2);
14345        assert_eq!(
14346            rows[0].content.as_ref().unwrap()[0].node_type,
14347            "tableHeader"
14348        );
14349        assert_eq!(rows[1].content.as_ref().unwrap()[0].node_type, "tableCell");
14350    }
14351
14352    #[test]
14353    fn directive_table_with_block_content() {
14354        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";
14355        let doc = markdown_to_adf(md).unwrap();
14356        let rows = doc.content[0].content.as_ref().unwrap();
14357        let cell = &rows[0].content.as_ref().unwrap()[0];
14358        // Cell should have block content (paragraph + bullet list)
14359        let content = cell.content.as_ref().unwrap();
14360        assert!(content.len() >= 2);
14361        assert_eq!(content[1].node_type, "bulletList");
14362    }
14363
14364    #[test]
14365    fn directive_table_with_cell_attrs() {
14366        let md = "::::table\n:::tr\n:::td{colspan=2 bg=#DEEBFF}\nSpanning cell\n:::\n:::\n::::\n";
14367        let doc = markdown_to_adf(md).unwrap();
14368        let cell = &doc.content[0].content.as_ref().unwrap()[0]
14369            .content
14370            .as_ref()
14371            .unwrap()[0];
14372        let attrs = cell.attrs.as_ref().unwrap();
14373        assert_eq!(attrs["colspan"], 2);
14374        assert_eq!(attrs["background"], "#DEEBFF");
14375    }
14376
14377    #[test]
14378    fn directive_table_with_css_var_background() {
14379        let bg = "var(--ds-background-accent-gray-subtlest, var(--ds-background-accent-gray-subtlest, #F1F2F4))";
14380        let md = format!("::::table\n:::tr\n:::th{{bg=\"{bg}\"}}\nHeader\n:::\n:::\n::::\n");
14381        let doc = markdown_to_adf(&md).unwrap();
14382        let row = &doc.content[0].content.as_ref().unwrap()[0];
14383        let cells = row.content.as_ref().unwrap();
14384        assert_eq!(cells.len(), 1, "row must have at least one cell");
14385        let attrs = cells[0].attrs.as_ref().unwrap();
14386        assert_eq!(attrs["background"], bg);
14387    }
14388
14389    #[test]
14390    fn css_var_background_round_trips() {
14391        let bg = "var(--ds-background-accent-gray-subtlest, #F1F2F4)";
14392        let adf = AdfDocument {
14393            version: 1,
14394            doc_type: "doc".to_string(),
14395            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
14396                AdfNode::table_header_with_attrs(
14397                    vec![AdfNode::paragraph(vec![AdfNode::text("Header")])],
14398                    serde_json::json!({"background": bg}),
14399                ),
14400            ])])],
14401        };
14402        let md = adf_to_markdown(&adf).unwrap();
14403        assert!(
14404            md.contains(&format!("bg=\"{bg}\"")),
14405            "bg value must be quoted in markdown: {md}"
14406        );
14407
14408        let round_tripped = markdown_to_adf(&md).unwrap();
14409        let row = &round_tripped.content[0].content.as_ref().unwrap()[0];
14410        let cells = row.content.as_ref().unwrap();
14411        assert_eq!(cells.len(), 1, "round-tripped row must have one cell");
14412        let rt_attrs = cells[0].attrs.as_ref().unwrap();
14413        assert_eq!(rt_attrs["background"], bg);
14414    }
14415
14416    #[test]
14417    fn directive_table_with_table_attrs() {
14418        let md = "::::table{layout=wide numbered}\n:::tr\n:::td\nCell\n:::\n:::\n::::\n";
14419        let doc = markdown_to_adf(md).unwrap();
14420        let attrs = doc.content[0].attrs.as_ref().unwrap();
14421        assert_eq!(attrs["layout"], "wide");
14422        assert_eq!(attrs["isNumberColumnEnabled"], true);
14423    }
14424
14425    #[test]
14426    fn adf_table_with_block_content_renders_directive_form() {
14427        // Table with a bullet list in a cell → should render as ::::table directive
14428        let doc = AdfDocument {
14429            version: 1,
14430            doc_type: "doc".to_string(),
14431            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
14432                AdfNode::table_cell(vec![
14433                    AdfNode::paragraph(vec![AdfNode::text("Cell with list:")]),
14434                    AdfNode::bullet_list(vec![AdfNode::list_item(vec![AdfNode::paragraph(vec![
14435                        AdfNode::text("Item 1"),
14436                    ])])]),
14437                ]),
14438            ])])],
14439        };
14440        let md = adf_to_markdown(&doc).unwrap();
14441        assert!(md.contains("::::table"));
14442        assert!(md.contains(":::td"));
14443        assert!(md.contains("- Item 1"));
14444    }
14445
14446    #[test]
14447    fn adf_table_inline_only_renders_pipe_form() {
14448        // Table with only inline content → pipe table
14449        let doc = AdfDocument {
14450            version: 1,
14451            doc_type: "doc".to_string(),
14452            content: vec![AdfNode::table(vec![
14453                AdfNode::table_row(vec![
14454                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
14455                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
14456                ]),
14457                AdfNode::table_row(vec![
14458                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C1")])]),
14459                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
14460                ]),
14461            ])],
14462        };
14463        let md = adf_to_markdown(&doc).unwrap();
14464        assert!(md.contains("| H1 | H2 |"));
14465        assert!(!md.contains("::::table"));
14466    }
14467
14468    #[test]
14469    fn adf_table_header_outside_first_row_renders_directive() {
14470        let doc = AdfDocument {
14471            version: 1,
14472            doc_type: "doc".to_string(),
14473            content: vec![AdfNode::table(vec![
14474                AdfNode::table_row(vec![
14475                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H")])]),
14476                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C")])]),
14477                ]),
14478                AdfNode::table_row(vec![
14479                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
14480                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
14481                ]),
14482            ])],
14483        };
14484        let md = adf_to_markdown(&doc).unwrap();
14485        assert!(md.contains("::::table"));
14486        assert!(md.contains(":::th"));
14487    }
14488
14489    #[test]
14490    fn adf_table_cell_attrs_rendered() {
14491        let doc = AdfDocument {
14492            version: 1,
14493            doc_type: "doc".to_string(),
14494            content: vec![AdfNode::table(vec![
14495                AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
14496                    AdfNode::text("H"),
14497                ])])]),
14498                AdfNode::table_row(vec![AdfNode::table_cell_with_attrs(
14499                    vec![AdfNode::paragraph(vec![AdfNode::text("C")])],
14500                    serde_json::json!({"background": "#DEEBFF", "colspan": 2}),
14501                )]),
14502            ])],
14503        };
14504        let md = adf_to_markdown(&doc).unwrap();
14505        assert!(md.contains("{colspan=2 bg=#DEEBFF}"));
14506    }
14507
14508    // ── Pipe table cell attrs tests ────────────────────────────────
14509
14510    #[test]
14511    fn pipe_table_cell_attrs() {
14512        let md = "| H1 | H2 |\n|---|---|\n| {bg=#DEEBFF} highlighted | normal |\n";
14513        let doc = markdown_to_adf(md).unwrap();
14514        let rows = doc.content[0].content.as_ref().unwrap();
14515        let cell = &rows[1].content.as_ref().unwrap()[0];
14516        let attrs = cell.attrs.as_ref().unwrap();
14517        assert_eq!(attrs["background"], "#DEEBFF");
14518    }
14519
14520    #[test]
14521    fn pipe_table_cell_colspan() {
14522        let md = "| H1 | H2 |\n|---|---|\n| {colspan=2} spanning |\n";
14523        let doc = markdown_to_adf(md).unwrap();
14524        let rows = doc.content[0].content.as_ref().unwrap();
14525        let cell = &rows[1].content.as_ref().unwrap()[0];
14526        let attrs = cell.attrs.as_ref().unwrap();
14527        assert_eq!(attrs["colspan"], 2);
14528    }
14529
14530    #[test]
14531    fn trailing_space_after_mention_in_table_cell_preserved() {
14532        // Issue #372: trailing space after mention in table cell was dropped
14533        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":[
14534          {"type":"mention","attrs":{"id":"aaa","text":"@Rob"}},
14535          {"type":"text","text":" "}
14536        ]}]}]}]}]}"#;
14537        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14538        let md = adf_to_markdown(&doc).unwrap();
14539        let round_tripped = markdown_to_adf(&md).unwrap();
14540        let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14541            .content
14542            .as_ref()
14543            .unwrap()[0];
14544        let para = &cell.content.as_ref().unwrap()[0];
14545        let inlines = para.content.as_ref().unwrap();
14546        assert!(
14547            inlines.len() >= 2,
14548            "expected mention + text(' ') nodes, got {} nodes: {:?}",
14549            inlines.len(),
14550            inlines.iter().map(|n| &n.node_type).collect::<Vec<_>>()
14551        );
14552        assert_eq!(inlines[0].node_type, "mention");
14553        assert_eq!(inlines[1].node_type, "text");
14554        assert_eq!(inlines[1].text.as_deref(), Some(" "));
14555    }
14556
14557    // ── Column alignment tests ─────────────────────────────────────
14558
14559    #[test]
14560    fn pipe_table_column_alignment() {
14561        let md = "| Left | Center | Right |\n|:---|:---:|---:|\n| L | C | R |\n";
14562        let doc = markdown_to_adf(md).unwrap();
14563        let rows = doc.content[0].content.as_ref().unwrap();
14564        // Header row
14565        let h_cells = rows[0].content.as_ref().unwrap();
14566        // Left → no mark
14567        assert!(h_cells[0].content.as_ref().unwrap()[0].marks.is_none());
14568        // Center → alignment center
14569        let center_marks = h_cells[1].content.as_ref().unwrap()[0]
14570            .marks
14571            .as_ref()
14572            .unwrap();
14573        assert_eq!(center_marks[0].attrs.as_ref().unwrap()["align"], "center");
14574        // Right → alignment end
14575        let right_marks = h_cells[2].content.as_ref().unwrap()[0]
14576            .marks
14577            .as_ref()
14578            .unwrap();
14579        assert_eq!(right_marks[0].attrs.as_ref().unwrap()["align"], "end");
14580    }
14581
14582    #[test]
14583    fn adf_table_alignment_roundtrip() {
14584        let doc = AdfDocument {
14585            version: 1,
14586            doc_type: "doc".to_string(),
14587            content: vec![AdfNode::table(vec![
14588                AdfNode::table_row(vec![
14589                    AdfNode::table_header(vec![{
14590                        let mut p = AdfNode::paragraph(vec![AdfNode::text("Center")]);
14591                        p.marks = Some(vec![AdfMark::alignment("center")]);
14592                        p
14593                    }]),
14594                    AdfNode::table_header(vec![{
14595                        let mut p = AdfNode::paragraph(vec![AdfNode::text("Right")]);
14596                        p.marks = Some(vec![AdfMark::alignment("end")]);
14597                        p
14598                    }]),
14599                ]),
14600                AdfNode::table_row(vec![
14601                    AdfNode::table_cell(vec![{
14602                        let mut p = AdfNode::paragraph(vec![AdfNode::text("C")]);
14603                        p.marks = Some(vec![AdfMark::alignment("center")]);
14604                        p
14605                    }]),
14606                    AdfNode::table_cell(vec![{
14607                        let mut p = AdfNode::paragraph(vec![AdfNode::text("R")]);
14608                        p.marks = Some(vec![AdfMark::alignment("end")]);
14609                        p
14610                    }]),
14611                ]),
14612            ])],
14613        };
14614        let md = adf_to_markdown(&doc).unwrap();
14615        assert!(md.contains(":---:"));
14616        assert!(md.contains("---:"));
14617    }
14618
14619    // ── Panel custom attrs tests ───────────────────────────────────
14620
14621    #[test]
14622    fn panel_custom_attrs_round_trip() {
14623        let md = ":::panel{type=custom icon=\":star:\" color=\"#DEEBFF\"}\nContent\n:::\n";
14624        let doc = markdown_to_adf(md).unwrap();
14625        let panel = &doc.content[0];
14626        let attrs = panel.attrs.as_ref().unwrap();
14627        assert_eq!(attrs["panelType"], "custom");
14628        assert_eq!(attrs["panelIcon"], ":star:");
14629        assert_eq!(attrs["panelColor"], "#DEEBFF");
14630
14631        let result = adf_to_markdown(&doc).unwrap();
14632        assert!(result.contains("type=custom"));
14633        assert!(result.contains("icon="));
14634        assert!(result.contains("color="));
14635    }
14636
14637    // ── Block card with attrs tests ────────────────────────────────
14638
14639    #[test]
14640    fn block_card_with_layout() {
14641        let md = "::card[https://example.com]{layout=wide}\n";
14642        let doc = markdown_to_adf(md).unwrap();
14643        let attrs = doc.content[0].attrs.as_ref().unwrap();
14644        assert_eq!(attrs["layout"], "wide");
14645
14646        let result = adf_to_markdown(&doc).unwrap();
14647        assert!(result.contains("::card[https://example.com]{layout=wide}"));
14648    }
14649
14650    // ── Extension params test ──────────────────────────────────────
14651
14652    #[test]
14653    fn extension_with_params() {
14654        let md = r#"::extension{type=com.atlassian.macro key=jira-chart params='{"jql":"project=PROJ"}'}"#;
14655        let doc = markdown_to_adf(&format!("{md}\n")).unwrap();
14656        let attrs = doc.content[0].attrs.as_ref().unwrap();
14657        assert_eq!(attrs["parameters"]["jql"], "project=PROJ");
14658    }
14659
14660    #[test]
14661    fn leaf_extension_layout_preserved_in_roundtrip() {
14662        // Issue #381: layout attr on extension nodes was dropped
14663        let adf_json = r#"{"version":1,"type":"doc","content":[
14664          {"type":"extension","attrs":{"extensionType":"com.atlassian.confluence.macro.core","extensionKey":"toc","layout":"default","parameters":{}}}
14665        ]}"#;
14666        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14667        let md = adf_to_markdown(&doc).unwrap();
14668        assert!(
14669            md.contains("layout=default"),
14670            "JFM should contain layout=default, got: {md}"
14671        );
14672        let round_tripped = markdown_to_adf(&md).unwrap();
14673        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14674        assert_eq!(attrs["layout"], "default", "layout should be preserved");
14675        assert_eq!(attrs["extensionKey"], "toc");
14676    }
14677
14678    #[test]
14679    fn bodied_extension_layout_preserved_in_roundtrip() {
14680        // Bodied extension with layout
14681        let adf_json = r#"{"version":1,"type":"doc","content":[
14682          {"type":"bodiedExtension","attrs":{"extensionType":"com.atlassian.macro","extensionKey":"expand","layout":"wide"},
14683           "content":[{"type":"paragraph","content":[{"type":"text","text":"inner"}]}]}
14684        ]}"#;
14685        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14686        let md = adf_to_markdown(&doc).unwrap();
14687        assert!(
14688            md.contains("layout=wide"),
14689            "JFM should contain layout=wide, got: {md}"
14690        );
14691        let round_tripped = markdown_to_adf(&md).unwrap();
14692        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14693        assert_eq!(attrs["layout"], "wide", "layout should be preserved");
14694    }
14695
14696    #[test]
14697    fn bodied_extension_parameters_preserved_in_roundtrip() {
14698        // Issue #473: parameters block inside bodiedExtension.attrs was dropped
14699        let adf_json = r#"{"version":1,"type":"doc","content":[
14700          {"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":{}}},
14701           "content":[{"type":"paragraph","content":[{"type":"text","text":"Content inside bodied extension"}]}]}
14702        ]}"#;
14703        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14704        let md = adf_to_markdown(&doc).unwrap();
14705        assert!(
14706            md.contains("params="),
14707            "JFM should contain params attribute, got: {md}"
14708        );
14709        let round_tripped = markdown_to_adf(&md).unwrap();
14710        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14711        assert_eq!(
14712            attrs["parameters"]["macroMetadata"]["title"], "Page Properties",
14713            "parameters should be preserved in round-trip"
14714        );
14715        assert_eq!(attrs["extensionKey"], "details");
14716        assert_eq!(attrs["layout"], "default");
14717        assert_eq!(attrs["localId"], "aabbccdd-1234");
14718    }
14719
14720    #[test]
14721    fn bodied_extension_malformed_params_ignored() {
14722        // Malformed params JSON should be silently ignored, not crash
14723        let md = ":::extension{type=com.atlassian.macro key=details params='not-valid-json'}\nContent\n:::\n";
14724        let doc = markdown_to_adf(md).unwrap();
14725        let attrs = doc.content[0].attrs.as_ref().unwrap();
14726        assert_eq!(attrs["extensionKey"], "details");
14727        // parameters should be absent since the JSON was invalid
14728        assert!(attrs.get("parameters").is_none());
14729    }
14730
14731    #[test]
14732    fn leaf_extension_localid_preserved_in_roundtrip() {
14733        // Extension with both layout and localId
14734        let adf_json = r#"{"version":1,"type":"doc","content":[
14735          {"type":"extension","attrs":{"extensionType":"com.atlassian.macro","extensionKey":"toc","layout":"default","localId":"abc-123"}}
14736        ]}"#;
14737        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14738        let md = adf_to_markdown(&doc).unwrap();
14739        let round_tripped = markdown_to_adf(&md).unwrap();
14740        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14741        assert_eq!(attrs["layout"], "default");
14742        assert_eq!(attrs["localId"], "abc-123");
14743    }
14744
14745    // ── Mention with userType test ─────────────────────────────────
14746
14747    #[test]
14748    fn mention_with_user_type() {
14749        let md = "Hi :mention[Alice]{id=abc123 userType=DEFAULT}.\n";
14750        let doc = markdown_to_adf(md).unwrap();
14751        let mention = &doc.content[0].content.as_ref().unwrap()[1];
14752        assert_eq!(mention.attrs.as_ref().unwrap()["userType"], "DEFAULT");
14753
14754        let result = adf_to_markdown(&doc).unwrap();
14755        assert!(result.contains("userType=DEFAULT"));
14756    }
14757
14758    // ── Colwidth tests ─────────────────────────────────────────────
14759
14760    #[test]
14761    fn directive_table_colwidth() {
14762        let md = "::::table\n:::tr\n:::td{colwidth=100,200}\nCell\n:::\n:::\n::::\n";
14763        let doc = markdown_to_adf(md).unwrap();
14764        let cell = &doc.content[0].content.as_ref().unwrap()[0]
14765            .content
14766            .as_ref()
14767            .unwrap()[0];
14768        let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
14769        assert_eq!(colwidth, &[serde_json::json!(100), serde_json::json!(200)]);
14770    }
14771
14772    #[test]
14773    fn directive_table_colwidth_float_roundtrip() {
14774        // Confluence returns colwidth as floats (e.g. 157.0, 863.0).
14775        // adf_to_markdown must preserve them so markdown_to_adf can restore them.
14776        let adf_doc = serde_json::json!({
14777            "type": "doc",
14778            "version": 1,
14779            "content": [{
14780                "type": "table",
14781                "content": [{
14782                    "type": "tableRow",
14783                    "content": [
14784                        {
14785                            "type": "tableHeader",
14786                            "attrs": { "colwidth": [157.0] },
14787                            "content": [{ "type": "paragraph" }]
14788                        },
14789                        {
14790                            "type": "tableHeader",
14791                            "attrs": { "colwidth": [863.0] },
14792                            "content": [{ "type": "paragraph" }]
14793                        }
14794                    ]
14795                }]
14796            }]
14797        });
14798        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
14799        let md = adf_to_markdown(&doc).unwrap();
14800        assert!(
14801            md.contains("colwidth=157.0"),
14802            "expected colwidth=157.0 in markdown, got: {md}"
14803        );
14804        assert!(
14805            md.contains("colwidth=863.0"),
14806            "expected colwidth=863.0 in markdown, got: {md}"
14807        );
14808        // Round-trip back to ADF
14809        let doc2 = markdown_to_adf(&md).unwrap();
14810        let row = &doc2.content[0].content.as_ref().unwrap()[0];
14811        let header1 = &row.content.as_ref().unwrap()[0];
14812        let header2 = &row.content.as_ref().unwrap()[1];
14813        assert_eq!(
14814            header1.attrs.as_ref().unwrap()["colwidth"]
14815                .as_array()
14816                .unwrap(),
14817            &[serde_json::json!(157.0)]
14818        );
14819        assert_eq!(
14820            header2.attrs.as_ref().unwrap()["colwidth"]
14821                .as_array()
14822                .unwrap(),
14823            &[serde_json::json!(863.0)]
14824        );
14825    }
14826
14827    #[test]
14828    fn colwidth_float_preserved_in_roundtrip() {
14829        // Issue #369: colwidth 254.0 was coerced to integer 254
14830        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":[]}]}]}]}]}"#;
14831        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14832        let md = adf_to_markdown(&doc).unwrap();
14833        let round_tripped = markdown_to_adf(&md).unwrap();
14834        let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14835            .content
14836            .as_ref()
14837            .unwrap()[0];
14838        let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
14839        assert_eq!(
14840            colwidth,
14841            &[serde_json::json!(254.0), serde_json::json!(416.0)],
14842            "colwidth should preserve float values"
14843        );
14844    }
14845
14846    #[test]
14847    fn colwidth_integer_preserved_in_roundtrip() {
14848        // Issue #459: colwidth integer values emitted as floats after round-trip
14849        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"}]}]}]}]}]}"#;
14850        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14851        let md = adf_to_markdown(&doc).unwrap();
14852        assert!(
14853            md.contains("colwidth=150"),
14854            "expected colwidth=150 (no decimal) in markdown, got: {md}"
14855        );
14856        assert!(
14857            !md.contains("colwidth=150.0"),
14858            "colwidth should not have .0 suffix for integers, got: {md}"
14859        );
14860        // Round-trip back to ADF
14861        let round_tripped = markdown_to_adf(&md).unwrap();
14862        let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14863            .content
14864            .as_ref()
14865            .unwrap()[0];
14866        let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
14867        assert_eq!(
14868            colwidth,
14869            &[serde_json::json!(150)],
14870            "colwidth should preserve integer values"
14871        );
14872        // Verify JSON serialization uses integer, not float
14873        let json_output = serde_json::to_string(&round_tripped).unwrap();
14874        assert!(
14875            json_output.contains(r#""colwidth":[150]"#),
14876            "JSON should contain integer colwidth, got: {json_output}"
14877        );
14878    }
14879
14880    #[test]
14881    fn colwidth_mixed_int_and_float_roundtrip() {
14882        // Integer colwidth from standard ADF and float colwidth from Confluence
14883        // should each preserve their original type through round-trip.
14884        let int_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colwidth":[100,200]}}]}]}]}"#;
14885        let float_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colwidth":[100.0,200.0]}}]}]}]}"#;
14886
14887        // Integer input → integer output
14888        let int_doc: AdfDocument = serde_json::from_str(int_json).unwrap();
14889        let int_md = adf_to_markdown(&int_doc).unwrap();
14890        assert!(
14891            int_md.contains("colwidth=100,200"),
14892            "integer colwidth in md: {int_md}"
14893        );
14894        let int_rt = markdown_to_adf(&int_md).unwrap();
14895        let int_serial = serde_json::to_string(&int_rt).unwrap();
14896        assert!(
14897            int_serial.contains(r#""colwidth":[100,200]"#),
14898            "integer colwidth in JSON: {int_serial}"
14899        );
14900
14901        // Float input → float output
14902        let float_doc: AdfDocument = serde_json::from_str(float_json).unwrap();
14903        let float_md = adf_to_markdown(&float_doc).unwrap();
14904        assert!(
14905            float_md.contains("colwidth=100.0,200.0"),
14906            "float colwidth in md: {float_md}"
14907        );
14908        let float_rt = markdown_to_adf(&float_md).unwrap();
14909        let float_serial = serde_json::to_string(&float_rt).unwrap();
14910        assert!(
14911            float_serial.contains(r#""colwidth":[100.0,200.0]"#),
14912            "float colwidth in JSON: {float_serial}"
14913        );
14914    }
14915
14916    #[test]
14917    fn colwidth_fractional_float_preserved() {
14918        // Covers the fractional-float branch (n.fract() != 0.0) in build_cell_attrs_string
14919        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"}]}]}]}]}]}"#;
14920        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14921        let md = adf_to_markdown(&doc).unwrap();
14922        assert!(
14923            md.contains("colwidth=100.5"),
14924            "expected colwidth=100.5 in markdown, got: {md}"
14925        );
14926    }
14927
14928    #[test]
14929    fn colwidth_non_numeric_values_skipped() {
14930        // Covers the None branch for non-numeric colwidth entries in build_cell_attrs_string
14931        let adf_doc = serde_json::json!({
14932            "type": "doc",
14933            "version": 1,
14934            "content": [{
14935                "type": "table",
14936                "content": [{
14937                    "type": "tableRow",
14938                    "content": [{
14939                        "type": "tableCell",
14940                        "attrs": { "colwidth": ["invalid"] },
14941                        "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "cell" }] }]
14942                    }]
14943                }]
14944            }]
14945        });
14946        let doc: AdfDocument = serde_json::from_value(adf_doc).unwrap();
14947        let md = adf_to_markdown(&doc).unwrap();
14948        // Non-numeric values are filtered out, so colwidth should not appear
14949        assert!(
14950            !md.contains("colwidth"),
14951            "non-numeric colwidth should be filtered out, got: {md}"
14952        );
14953    }
14954
14955    #[test]
14956    fn default_rowspan_colspan_preserved_in_roundtrip() {
14957        // Issue #369: rowspan=1 and colspan=1 were elided
14958        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"}]}]}]}]}]}"#;
14959        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14960        let md = adf_to_markdown(&doc).unwrap();
14961        let round_tripped = markdown_to_adf(&md).unwrap();
14962        let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14963            .content
14964            .as_ref()
14965            .unwrap()[0];
14966        let attrs = cell.attrs.as_ref().unwrap();
14967        assert_eq!(attrs["rowspan"], 1, "rowspan=1 should be preserved");
14968        assert_eq!(attrs["colspan"], 1, "colspan=1 should be preserved");
14969    }
14970
14971    // ── Nested list tests ──────────────────────────────────────────────
14972
14973    #[test]
14974    fn table_localid_preserved_in_roundtrip() {
14975        // Issue #374: localId on table nodes was dropped
14976        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"}]}]}]}]}]}"#;
14977        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14978        let md = adf_to_markdown(&doc).unwrap();
14979        assert!(
14980            md.contains("localId="),
14981            "JFM should contain localId, got: {md}"
14982        );
14983        let round_tripped = markdown_to_adf(&md).unwrap();
14984        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14985        assert_eq!(
14986            attrs["localId"], "7afd4550-e66c-4b12-875f-a91c6c7b62c7",
14987            "localId should be preserved"
14988        );
14989    }
14990
14991    #[test]
14992    fn paragraph_localid_preserved_in_roundtrip() {
14993        // Issue #399: localId on paragraph nodes was dropped
14994        let adf_json = r#"{"version":1,"type":"doc","content":[
14995          {"type":"paragraph","attrs":{"localId":"abc-123"},"content":[{"type":"text","text":"hello"}]}
14996        ]}"#;
14997        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14998        let md = adf_to_markdown(&doc).unwrap();
14999        assert!(
15000            md.contains("localId=abc-123"),
15001            "JFM should contain localId, got: {md}"
15002        );
15003        let round_tripped = markdown_to_adf(&md).unwrap();
15004        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15005        assert_eq!(attrs["localId"], "abc-123", "localId should be preserved");
15006    }
15007
15008    #[test]
15009    fn heading_localid_preserved_in_roundtrip() {
15010        let adf_json = r#"{"version":1,"type":"doc","content":[
15011          {"type":"heading","attrs":{"level":2,"localId":"h-456"},"content":[{"type":"text","text":"Title"}]}
15012        ]}"#;
15013        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15014        let md = adf_to_markdown(&doc).unwrap();
15015        let round_tripped = markdown_to_adf(&md).unwrap();
15016        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15017        assert_eq!(attrs["localId"], "h-456");
15018    }
15019
15020    #[test]
15021    fn localid_with_alignment_preserved() {
15022        // localId and alignment marks should coexist in the same {attrs} block
15023        let adf_json = r#"{"version":1,"type":"doc","content":[
15024          {"type":"paragraph","attrs":{"localId":"p-789"},"marks":[{"type":"alignment","attrs":{"align":"center"}}],
15025           "content":[{"type":"text","text":"centered"}]}
15026        ]}"#;
15027        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15028        let md = adf_to_markdown(&doc).unwrap();
15029        assert!(md.contains("localId=p-789"), "should have localId: {md}");
15030        assert!(md.contains("align=center"), "should have align: {md}");
15031        let round_tripped = markdown_to_adf(&md).unwrap();
15032        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15033        assert_eq!(attrs["localId"], "p-789");
15034        let marks = round_tripped.content[0].marks.as_ref().unwrap();
15035        assert!(marks.iter().any(|m| m.mark_type == "alignment"));
15036    }
15037
15038    #[test]
15039    fn table_layout_default_preserved_in_roundtrip() {
15040        // Issue #380: layout='default' was elided
15041        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"}]}]}]}]}]}"#;
15042        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15043        let md = adf_to_markdown(&doc).unwrap();
15044        let round_tripped = markdown_to_adf(&md).unwrap();
15045        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15046        assert_eq!(
15047            attrs["layout"], "default",
15048            "layout='default' should be preserved"
15049        );
15050    }
15051
15052    #[test]
15053    fn table_is_number_column_enabled_false_preserved() {
15054        // Issue #380: isNumberColumnEnabled=false was elided
15055        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"}]}]}]}]}]}"#;
15056        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15057        let md = adf_to_markdown(&doc).unwrap();
15058        let round_tripped = markdown_to_adf(&md).unwrap();
15059        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15060        assert_eq!(
15061            attrs["isNumberColumnEnabled"], false,
15062            "isNumberColumnEnabled=false should be preserved"
15063        );
15064    }
15065
15066    #[test]
15067    fn table_is_number_column_enabled_true_preserved() {
15068        // Regression check: isNumberColumnEnabled=true should still work
15069        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"}]}]}]}]}]}"#;
15070        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15071        let md = adf_to_markdown(&doc).unwrap();
15072        let round_tripped = markdown_to_adf(&md).unwrap();
15073        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15074        assert_eq!(
15075            attrs["isNumberColumnEnabled"], true,
15076            "isNumberColumnEnabled=true should be preserved"
15077        );
15078    }
15079
15080    #[test]
15081    fn directive_table_is_number_column_enabled_false_preserved() {
15082        // Covers render_directive_table + directive table parsing for numbered=false.
15083        // Multi-paragraph cell forces directive table form.
15084        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[
15085          {"type":"paragraph","content":[{"type":"text","text":"line one"}]},
15086          {"type":"paragraph","content":[{"type":"text","text":"line two"}]}
15087        ]}]}]}]}"#;
15088        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15089        let md = adf_to_markdown(&doc).unwrap();
15090        assert!(md.contains("::::table"), "should use directive table form");
15091        assert!(
15092            md.contains("numbered=false"),
15093            "should contain numbered=false, got: {md}"
15094        );
15095        let round_tripped = markdown_to_adf(&md).unwrap();
15096        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15097        assert_eq!(attrs["isNumberColumnEnabled"], false);
15098        assert_eq!(attrs["layout"], "default");
15099    }
15100
15101    #[test]
15102    fn directive_table_is_number_column_enabled_true_preserved() {
15103        // Covers render_directive_table + directive table parsing for numbered (true).
15104        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":true,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[
15105          {"type":"paragraph","content":[{"type":"text","text":"line one"}]},
15106          {"type":"paragraph","content":[{"type":"text","text":"line two"}]}
15107        ]}]}]}]}"#;
15108        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15109        let md = adf_to_markdown(&doc).unwrap();
15110        assert!(md.contains("::::table"), "should use directive table form");
15111        assert!(
15112            md.contains("numbered}") || md.contains("numbered "),
15113            "should contain numbered flag, got: {md}"
15114        );
15115        let round_tripped = markdown_to_adf(&md).unwrap();
15116        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15117        assert_eq!(attrs["isNumberColumnEnabled"], true);
15118    }
15119
15120    #[test]
15121    fn trailing_space_in_bullet_list_item_preserved() {
15122        // Issue #394: trailing space text node in list item dropped
15123        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
15124          {"type":"listItem","content":[{"type":"paragraph","content":[
15125            {"type":"text","text":"Before link "},
15126            {"type":"text","text":"link text","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
15127            {"type":"text","text":" "}
15128          ]}]}
15129        ]}]}"#;
15130        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15131        let md = adf_to_markdown(&doc).unwrap();
15132        let round_tripped = markdown_to_adf(&md).unwrap();
15133        let list = &round_tripped.content[0];
15134        let item = &list.content.as_ref().unwrap()[0];
15135        let para = &item.content.as_ref().unwrap()[0];
15136        let inlines = para.content.as_ref().unwrap();
15137        let last = inlines.last().unwrap();
15138        assert_eq!(
15139            last.text.as_deref(),
15140            Some(" "),
15141            "trailing space text node should be preserved, got nodes: {:?}",
15142            inlines
15143                .iter()
15144                .map(|n| (&n.node_type, &n.text))
15145                .collect::<Vec<_>>()
15146        );
15147    }
15148
15149    #[test]
15150    fn trailing_space_after_mention_in_bullet_list_preserved() {
15151        // Mention + trailing space in list item
15152        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
15153          {"type":"listItem","content":[{"type":"paragraph","content":[
15154            {"type":"mention","attrs":{"id":"abc","text":"@Alice"}},
15155            {"type":"text","text":" "}
15156          ]}]}
15157        ]}]}"#;
15158        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15159        let md = adf_to_markdown(&doc).unwrap();
15160        let round_tripped = markdown_to_adf(&md).unwrap();
15161        let para = &round_tripped.content[0].content.as_ref().unwrap()[0]
15162            .content
15163            .as_ref()
15164            .unwrap()[0];
15165        let inlines = para.content.as_ref().unwrap();
15166        assert!(
15167            inlines.len() >= 2,
15168            "should have mention + trailing space, got {} nodes",
15169            inlines.len()
15170        );
15171        assert_eq!(inlines.last().unwrap().text.as_deref(), Some(" "));
15172    }
15173
15174    #[test]
15175    fn trailing_space_in_ordered_list_item_preserved() {
15176        // Same issue in ordered list context
15177        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
15178          {"type":"listItem","content":[{"type":"paragraph","content":[
15179            {"type":"text","text":"item "},
15180            {"type":"text","text":"link","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
15181            {"type":"text","text":" "}
15182          ]}]}
15183        ]}]}"#;
15184        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15185        let md = adf_to_markdown(&doc).unwrap();
15186        let round_tripped = markdown_to_adf(&md).unwrap();
15187        let para = &round_tripped.content[0].content.as_ref().unwrap()[0]
15188            .content
15189            .as_ref()
15190            .unwrap()[0];
15191        let inlines = para.content.as_ref().unwrap();
15192        let last = inlines.last().unwrap();
15193        assert_eq!(
15194            last.text.as_deref(),
15195            Some(" "),
15196            "trailing space should be preserved in ordered list item"
15197        );
15198    }
15199
15200    #[test]
15201    fn trailing_space_in_heading_text_preserved() {
15202        // Issue #400: trailing space in heading text node trimmed on round-trip
15203        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[
15204          {"type":"text","text":"Firefighting Engineers "}
15205        ]}]}"#;
15206        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15207        let md = adf_to_markdown(&doc).unwrap();
15208        let round_tripped = markdown_to_adf(&md).unwrap();
15209        let inlines = round_tripped.content[0].content.as_ref().unwrap();
15210        assert_eq!(
15211            inlines[0].text.as_deref(),
15212            Some("Firefighting Engineers "),
15213            "trailing space in heading should be preserved"
15214        );
15215    }
15216
15217    #[test]
15218    fn trailing_space_in_heading_before_bold_preserved() {
15219        // Issue #400: trailing space before bold sibling in heading
15220        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[
15221          {"type":"text","text":"Classic "},
15222          {"type":"text","text":"bold","marks":[{"type":"strong"}]}
15223        ]}]}"#;
15224        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15225        let md = adf_to_markdown(&doc).unwrap();
15226        let round_tripped = markdown_to_adf(&md).unwrap();
15227        let inlines = round_tripped.content[0].content.as_ref().unwrap();
15228        assert_eq!(
15229            inlines[0].text.as_deref(),
15230            Some("Classic "),
15231            "trailing space in heading text before bold should be preserved"
15232        );
15233    }
15234
15235    #[test]
15236    fn leading_space_in_heading_text_preserved() {
15237        // Issue #492: leading spaces in heading text node stripped on round-trip
15238        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":3},"content":[
15239          {"type":"text","text":"  #general-channel"}
15240        ]}]}"#;
15241        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15242        let md = adf_to_markdown(&doc).unwrap();
15243        let round_tripped = markdown_to_adf(&md).unwrap();
15244        let inlines = round_tripped.content[0].content.as_ref().unwrap();
15245        assert_eq!(
15246            inlines[0].text.as_deref(),
15247            Some("  #general-channel"),
15248            "leading spaces in heading text should be preserved"
15249        );
15250    }
15251
15252    #[test]
15253    fn leading_space_in_heading_before_bold_preserved() {
15254        // Issue #492: leading space before bold sibling in heading
15255        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[
15256          {"type":"text","text":"   indented"},
15257          {"type":"text","text":" bold","marks":[{"type":"strong"}]}
15258        ]}]}"#;
15259        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15260        let md = adf_to_markdown(&doc).unwrap();
15261        let round_tripped = markdown_to_adf(&md).unwrap();
15262        let inlines = round_tripped.content[0].content.as_ref().unwrap();
15263        assert_eq!(
15264            inlines[0].text.as_deref(),
15265            Some("   indented"),
15266            "leading spaces in heading text before bold should be preserved"
15267        );
15268    }
15269
15270    #[test]
15271    fn heading_multiple_leading_spaces_markdown_parse() {
15272        // Issue #492: verify JFM parsing preserves leading spaces
15273        let md = "### \t  #general-channel";
15274        let doc = markdown_to_adf(md).unwrap();
15275        let inlines = doc.content[0].content.as_ref().unwrap();
15276        assert_eq!(
15277            inlines[0].text.as_deref(),
15278            Some("\t  #general-channel"),
15279            "leading whitespace in heading text should be preserved during JFM parsing"
15280        );
15281    }
15282
15283    #[test]
15284    fn trailing_space_in_paragraph_text_preserved() {
15285        // Issue #400: trailing space in paragraph text node preserved on round-trip
15286        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
15287          {"type":"text","text":"word followed by space "},
15288          {"type":"text","text":"next node","marks":[{"type":"strong"}]}
15289        ]}]}"#;
15290        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15291        let md = adf_to_markdown(&doc).unwrap();
15292        let round_tripped = markdown_to_adf(&md).unwrap();
15293        let inlines = round_tripped.content[0].content.as_ref().unwrap();
15294        assert_eq!(
15295            inlines[0].text.as_deref(),
15296            Some("word followed by space "),
15297            "trailing space in paragraph text should be preserved"
15298        );
15299    }
15300
15301    #[test]
15302    fn nested_bullet_list_roundtrip() {
15303        // ADF with a listItem containing a paragraph + nested bulletList
15304        let adf_doc = serde_json::json!({
15305            "type": "doc",
15306            "version": 1,
15307            "content": [{
15308                "type": "bulletList",
15309                "content": [{
15310                    "type": "listItem",
15311                    "content": [
15312                        {
15313                            "type": "paragraph",
15314                            "content": [{"type": "text", "text": "parent item"}]
15315                        },
15316                        {
15317                            "type": "bulletList",
15318                            "content": [
15319                                {
15320                                    "type": "listItem",
15321                                    "content": [{
15322                                        "type": "paragraph",
15323                                        "content": [{"type": "text", "text": "sub item 1"}]
15324                                    }]
15325                                },
15326                                {
15327                                    "type": "listItem",
15328                                    "content": [{
15329                                        "type": "paragraph",
15330                                        "content": [{"type": "text", "text": "sub item 2"}]
15331                                    }]
15332                                }
15333                            ]
15334                        }
15335                    ]
15336                }]
15337            }]
15338        });
15339        let doc: AdfDocument = serde_json::from_value(adf_doc).unwrap();
15340        let md = adf_to_markdown(&doc).unwrap();
15341        assert!(
15342            md.contains("- parent item\n"),
15343            "expected top-level item in markdown, got: {md}"
15344        );
15345        assert!(
15346            md.contains("  - sub item 1\n"),
15347            "expected indented sub item 1 in markdown, got: {md}"
15348        );
15349        assert!(
15350            md.contains("  - sub item 2\n"),
15351            "expected indented sub item 2 in markdown, got: {md}"
15352        );
15353
15354        // Round-trip back
15355        let doc2 = markdown_to_adf(&md).unwrap();
15356        let list = &doc2.content[0];
15357        assert_eq!(list.node_type, "bulletList");
15358        let item = &list.content.as_ref().unwrap()[0];
15359        assert_eq!(item.node_type, "listItem");
15360        let item_content = item.content.as_ref().unwrap();
15361        assert_eq!(
15362            item_content.len(),
15363            2,
15364            "listItem should have paragraph + nested list"
15365        );
15366        assert_eq!(item_content[0].node_type, "paragraph");
15367        assert_eq!(item_content[1].node_type, "bulletList");
15368        let sub_items = item_content[1].content.as_ref().unwrap();
15369        assert_eq!(sub_items.len(), 2);
15370    }
15371
15372    #[test]
15373    fn nested_bullet_in_table_cell_roundtrip() {
15374        let md = "::::table\n:::tr\n:::td\n- parent\n  - child\n:::\n:::\n::::\n";
15375        let doc = markdown_to_adf(md).unwrap();
15376        let table = &doc.content[0];
15377        let row = &table.content.as_ref().unwrap()[0];
15378        let cell = &row.content.as_ref().unwrap()[0];
15379        let list = &cell.content.as_ref().unwrap()[0];
15380        assert_eq!(list.node_type, "bulletList");
15381        let item = &list.content.as_ref().unwrap()[0];
15382        let item_content = item.content.as_ref().unwrap();
15383        assert_eq!(
15384            item_content.len(),
15385            2,
15386            "listItem should have paragraph + nested list"
15387        );
15388        assert_eq!(item_content[1].node_type, "bulletList");
15389
15390        // Round-trip: adf→md→adf should preserve the nested list
15391        let md2 = adf_to_markdown(&doc).unwrap();
15392        assert!(
15393            md2.contains("  - child"),
15394            "expected indented child in round-tripped markdown, got: {md2}"
15395        );
15396    }
15397
15398    #[test]
15399    fn nested_ordered_list_roundtrip() {
15400        // Issue #389: nested orderedList inside listItem flattened
15401        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
15402          {"type":"listItem","content":[
15403            {"type":"paragraph","content":[{"type":"text","text":"Top level"}]},
15404            {"type":"orderedList","attrs":{"order":1},"content":[
15405              {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Nested 1"}]}]},
15406              {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Nested 2"}]}]}
15407            ]}
15408          ]},
15409          {"type":"listItem","content":[
15410            {"type":"paragraph","content":[{"type":"text","text":"Second top"}]}
15411          ]}
15412        ]}]}"#;
15413        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15414        let md = adf_to_markdown(&doc).unwrap();
15415        let round_tripped = markdown_to_adf(&md).unwrap();
15416
15417        // Outer list should have 2 items
15418        let outer = &round_tripped.content[0];
15419        assert_eq!(outer.node_type, "orderedList");
15420        assert_eq!(
15421            outer.attrs.as_ref().unwrap()["order"],
15422            1,
15423            "explicit order=1 must be preserved via trailing {{order=1}} (issue #547)"
15424        );
15425        let outer_items = outer.content.as_ref().unwrap();
15426        assert_eq!(
15427            outer_items.len(),
15428            2,
15429            "outer list should have 2 items, got {}",
15430            outer_items.len()
15431        );
15432
15433        // First item should have paragraph + nested orderedList
15434        let first_item = &outer_items[0];
15435        let first_content = first_item.content.as_ref().unwrap();
15436        assert_eq!(
15437            first_content.len(),
15438            2,
15439            "first listItem should have paragraph + nested list, got {}",
15440            first_content.len()
15441        );
15442        assert_eq!(first_content[0].node_type, "paragraph");
15443        assert_eq!(first_content[1].node_type, "orderedList");
15444        let nested_items = first_content[1].content.as_ref().unwrap();
15445        assert_eq!(nested_items.len(), 2, "nested list should have 2 items");
15446    }
15447
15448    #[test]
15449    fn nested_ordered_list_markdown_parsing() {
15450        // Direct markdown parsing of nested ordered list
15451        let md = "1. Top level\n  1. Nested 1\n  2. Nested 2\n2. Second top\n";
15452        let doc = markdown_to_adf(md).unwrap();
15453        let outer = &doc.content[0];
15454        assert_eq!(outer.node_type, "orderedList");
15455        let outer_items = outer.content.as_ref().unwrap();
15456        assert_eq!(outer_items.len(), 2, "should have 2 top-level items");
15457
15458        let first_content = outer_items[0].content.as_ref().unwrap();
15459        assert_eq!(
15460            first_content.len(),
15461            2,
15462            "first item should have paragraph + nested list"
15463        );
15464        assert_eq!(first_content[1].node_type, "orderedList");
15465    }
15466
15467    #[test]
15468    fn bullet_list_nested_inside_ordered_list() {
15469        // Mixed nesting: bullet list nested inside ordered list
15470        let md = "1. Ordered item\n  - Bullet child 1\n  - Bullet child 2\n2. Second ordered\n";
15471        let doc = markdown_to_adf(md).unwrap();
15472        let outer = &doc.content[0];
15473        assert_eq!(outer.node_type, "orderedList");
15474        let outer_items = outer.content.as_ref().unwrap();
15475        assert_eq!(outer_items.len(), 2);
15476
15477        let first_content = outer_items[0].content.as_ref().unwrap();
15478        assert_eq!(
15479            first_content.len(),
15480            2,
15481            "first item should have paragraph + nested list"
15482        );
15483        assert_eq!(first_content[1].node_type, "bulletList");
15484        let sub_items = first_content[1].content.as_ref().unwrap();
15485        assert_eq!(sub_items.len(), 2, "nested bullet list should have 2 items");
15486    }
15487
15488    #[test]
15489    fn ordered_list_order_attr_one_is_elided() {
15490        // Issue #547: order=1 is the default and must be elided from attrs
15491        // for round-trip fidelity with ADF documents that omit the attrs
15492        // object on orderedList.
15493        let md = "1. A\n2. B\n";
15494        let doc = markdown_to_adf(md).unwrap();
15495        assert!(
15496            doc.content[0].attrs.is_none(),
15497            "attrs should be elided when order=1"
15498        );
15499
15500        // Round-trip should preserve the elision
15501        let md2 = adf_to_markdown(&doc).unwrap();
15502        let doc2 = markdown_to_adf(&md2).unwrap();
15503        assert!(
15504            doc2.content[0].attrs.is_none(),
15505            "attrs should remain elided after round-trip"
15506        );
15507    }
15508
15509    #[test]
15510    fn issue_547_ordered_list_no_attrs_roundtrip_byte_identical() {
15511        // Issue #547: ADF orderedList without an attrs field must round-trip
15512        // (ADF → JFM → ADF) without gaining a spurious {"order": 1} attrs.
15513        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"}]}]}]}]}"#;
15514        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15515        let md = adf_to_markdown(&doc).unwrap();
15516        let rt = markdown_to_adf(&md).unwrap();
15517        assert!(
15518            rt.content[0].attrs.is_none(),
15519            "round-tripped orderedList should not have attrs, got: {:?}",
15520            rt.content[0].attrs
15521        );
15522
15523        // Serialized JSON must also omit attrs entirely for byte fidelity.
15524        let rt_json = serde_json::to_string(&rt).unwrap();
15525        assert!(
15526            !rt_json.contains("\"order\""),
15527            "round-tripped JSON should not contain \"order\", got: {rt_json}"
15528        );
15529    }
15530
15531    // ── Issue #547: orderedList byte-identical roundtrip coverage ───────
15532
15533    /// Assert that ADF → JFM → ADF produces a document whose serialized JSON
15534    /// (as a sorted-key canonical form) matches the source JSON. Mirrors the
15535    /// `jq --sort-keys` comparison used in the issue's reproducer.
15536    fn assert_roundtrip_byte_identical(adf_json: &str) {
15537        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15538        let md = adf_to_markdown(&doc).unwrap();
15539        let rt = markdown_to_adf(&md).unwrap();
15540
15541        let canonical_src: serde_json::Value = serde_json::from_str(adf_json).unwrap();
15542        let canonical_rt: serde_json::Value =
15543            serde_json::from_str(&serde_json::to_string(&rt).unwrap()).unwrap();
15544        assert_eq!(
15545            canonical_src, canonical_rt,
15546            "round-trip diverged\n  src: {canonical_src}\n   rt: {canonical_rt}\n   md: {md:?}"
15547        );
15548    }
15549
15550    #[test]
15551    fn issue_547_single_item_no_attrs_roundtrip() {
15552        assert_roundtrip_byte_identical(
15553            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"only"}]}]}]}]}"#,
15554        );
15555    }
15556
15557    #[test]
15558    fn issue_547_many_items_no_attrs_roundtrip() {
15559        assert_roundtrip_byte_identical(
15560            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"}]}]}]}]}"#,
15561        );
15562    }
15563
15564    #[test]
15565    fn issue_547_non_default_order_preserved() {
15566        // When order != 1, attrs must still be serialized (fix must not
15567        // over-eagerly drop attrs).
15568        assert_roundtrip_byte_identical(
15569            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":5},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"fifth"}]}]}]}]}"#,
15570        );
15571    }
15572
15573    #[test]
15574    fn issue_547_nested_ordered_in_ordered_no_attrs_roundtrip() {
15575        // Outer and inner both omit attrs; fix must apply at every level.
15576        assert_roundtrip_byte_identical(
15577            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"}]}]}]}]}]}]}"#,
15578        );
15579    }
15580
15581    #[test]
15582    fn issue_547_ordered_nested_in_bullet_no_attrs_roundtrip() {
15583        assert_roundtrip_byte_identical(
15584            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"}]}]}]}]}]}]}"#,
15585        );
15586    }
15587
15588    #[test]
15589    fn issue_547_bullet_nested_in_ordered_no_attrs_roundtrip() {
15590        assert_roundtrip_byte_identical(
15591            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"}]}]}]}]}]}]}"#,
15592        );
15593    }
15594
15595    #[test]
15596    fn issue_547_ordered_list_between_paragraphs_roundtrip() {
15597        assert_roundtrip_byte_identical(
15598            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"}]}]}"#,
15599        );
15600    }
15601
15602    #[test]
15603    fn issue_547_ordered_list_with_marked_text_roundtrip() {
15604        assert_roundtrip_byte_identical(
15605            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"bold","marks":[{"type":"strong"}]}]}]}]}]}"#,
15606        );
15607    }
15608
15609    #[test]
15610    fn issue_547_ordered_list_with_link_roundtrip() {
15611        assert_roundtrip_byte_identical(
15612            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"}}]}]}]}]}]}"#,
15613        );
15614    }
15615
15616    #[test]
15617    fn issue_547_ordered_list_with_hardbreak_roundtrip() {
15618        assert_roundtrip_byte_identical(
15619            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"}]}]}]}]}"#,
15620        );
15621    }
15622
15623    #[test]
15624    fn issue_547_triple_nested_ordered_roundtrip() {
15625        assert_roundtrip_byte_identical(
15626            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"}]}]}]}]}]}]}]}]}"#,
15627        );
15628    }
15629
15630    #[test]
15631    fn issue_547_ordered_list_heading_rule_mix_roundtrip() {
15632        assert_roundtrip_byte_identical(
15633            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"}]}"#,
15634        );
15635    }
15636
15637    #[test]
15638    fn issue_547_ordered_list_listitem_localid_roundtrip() {
15639        // listItem attrs must coexist with the no-attrs outer orderedList.
15640        assert_roundtrip_byte_identical(
15641            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","attrs":{"localId":"li-001"},"content":[{"type":"paragraph","content":[{"type":"text","text":"first"}]}]}]}]}"#,
15642        );
15643    }
15644
15645    #[test]
15646    fn issue_547_explicit_order_one_preserved_roundtrip() {
15647        // Inverse regression (see PR #562 comment 4266630848): when the source
15648        // ADF has an explicit `"attrs": {"order": 1}` the round-trip must
15649        // preserve it, not strip it. A trailing `{order=1}` signal on the
15650        // rendered markdown distinguishes explicit-default from omitted attrs.
15651        assert_roundtrip_byte_identical(
15652            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"First item"}]}]}]}]}"#,
15653        );
15654    }
15655
15656    #[test]
15657    fn issue_547_explicit_order_one_nested_preserved_roundtrip() {
15658        // Both outer and inner orderedList have explicit `order: 1`; both must
15659        // be preserved across the round-trip independently.
15660        assert_roundtrip_byte_identical(
15661            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"}]}]}]}]}]}]}"#,
15662        );
15663    }
15664
15665    #[test]
15666    fn issue_547_mixed_explicit_and_implicit_order_roundtrip() {
15667        // Sibling orderedLists with different attrs presence must round-trip
15668        // independently: first has explicit `order: 1`, second omits attrs.
15669        assert_roundtrip_byte_identical(
15670            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"}]}]}]}]}"#,
15671        );
15672    }
15673
15674    #[test]
15675    fn issue_547_explicit_order_one_with_listitem_localid_roundtrip() {
15676        // Explicit `order: 1` outer, plus a listItem `localId` inside — the
15677        // trailing `{order=1}` line must not swallow or collide with listItem
15678        // attrs.
15679        assert_roundtrip_byte_identical(
15680            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"}]}]}]}]}"#,
15681        );
15682    }
15683
15684    #[test]
15685    fn issue_547_order_attr_signal_appears_only_for_explicit_one() {
15686        // Render-layer guard: `{order=1}` appears in markdown only when the
15687        // source ADF has explicit `attrs.order=1`. No signal for attrs=None,
15688        // no signal for attrs.order>1 (marker already encodes the value).
15689        let no_attrs = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]}]}"#;
15690        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"}]}]}]}]}"#;
15691        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"}]}]}]}]}"#;
15692
15693        let md_no =
15694            adf_to_markdown(&serde_json::from_str::<AdfDocument>(no_attrs).unwrap()).unwrap();
15695        let md_one =
15696            adf_to_markdown(&serde_json::from_str::<AdfDocument>(explicit_one).unwrap()).unwrap();
15697        let md_five =
15698            adf_to_markdown(&serde_json::from_str::<AdfDocument>(order_five).unwrap()).unwrap();
15699
15700        assert!(
15701            !md_no.contains("{order="),
15702            "no-attrs source must not emit order signal, got: {md_no:?}"
15703        );
15704        assert!(
15705            md_one.contains("{order=1}"),
15706            "explicit order=1 must emit trailing signal, got: {md_one:?}"
15707        );
15708        assert!(
15709            !md_five.contains("{order="),
15710            "order=5 is already encoded by marker; must not emit signal, got: {md_five:?}"
15711        );
15712    }
15713
15714    // ── File media round-trip tests ─────────────────────────────────────
15715
15716    #[test]
15717    fn file_media_roundtrip() {
15718        // ADF with a Confluence file attachment (type:file media)
15719        let adf_doc = serde_json::json!({
15720            "type": "doc",
15721            "version": 1,
15722            "content": [{
15723                "type": "mediaSingle",
15724                "attrs": {"layout": "center"},
15725                "content": [{
15726                    "type": "media",
15727                    "attrs": {
15728                        "type": "file",
15729                        "id": "6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d",
15730                        "collection": "contentId-8220672100",
15731                        "height": 56,
15732                        "width": 312,
15733                        "alt": "Screenshot.png"
15734                    }
15735                }]
15736            }]
15737        });
15738        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
15739        let md = adf_to_markdown(&doc).unwrap();
15740        assert!(
15741            md.contains("type=file"),
15742            "expected type=file in markdown, got: {md}"
15743        );
15744        assert!(
15745            md.contains("id=6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d"),
15746            "expected id in markdown, got: {md}"
15747        );
15748        assert!(
15749            md.contains("collection=contentId-8220672100"),
15750            "expected collection in markdown, got: {md}"
15751        );
15752        // Round-trip back to ADF
15753        let doc2 = markdown_to_adf(&md).unwrap();
15754        let ms = &doc2.content[0];
15755        assert_eq!(ms.node_type, "mediaSingle");
15756        let media = &ms.content.as_ref().unwrap()[0];
15757        assert_eq!(media.node_type, "media");
15758        let attrs = media.attrs.as_ref().unwrap();
15759        assert_eq!(attrs["type"], "file");
15760        assert_eq!(attrs["id"], "6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d");
15761        assert_eq!(attrs["collection"], "contentId-8220672100");
15762        assert_eq!(attrs["height"], 56);
15763        assert_eq!(attrs["width"], 312);
15764        assert_eq!(attrs["alt"], "Screenshot.png");
15765    }
15766
15767    /// Issue #550: roundtrip of mediaSingle with file-type media preserves all
15768    /// file attributes (type, id, collection, width, height). Regression guard
15769    /// for the exact reproducer in the issue body.
15770    #[test]
15771    fn file_media_roundtrip_issue_550_reproducer() {
15772        let adf_json = r#"{
15773          "version": 1,
15774          "type": "doc",
15775          "content": [
15776            {
15777              "type": "mediaSingle",
15778              "attrs": {"layout": "center"},
15779              "content": [
15780                {
15781                  "type": "media",
15782                  "attrs": {
15783                    "type": "file",
15784                    "id": "abc-123-def-456",
15785                    "collection": "my-collection",
15786                    "width": 941,
15787                    "height": 655
15788                  }
15789                }
15790              ]
15791            }
15792          ]
15793        }"#;
15794        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15795        let md = adf_to_markdown(&doc).unwrap();
15796        let rt = markdown_to_adf(&md).unwrap();
15797        let expected: serde_json::Value = serde_json::from_str(adf_json).unwrap();
15798        let actual = serde_json::to_value(&rt).unwrap();
15799        assert_eq!(
15800            actual, expected,
15801            "roundtrip should preserve file media attrs; md was:\n{md}"
15802        );
15803    }
15804
15805    /// Issue #550 (updated reproducer): roundtrip of a file-media `id`
15806    /// containing spaces must not truncate the value. Before the fix, the
15807    /// JFM renderer emitted `id=abc 123 def 456` unquoted and the parser
15808    /// treated the first space as a value terminator, so the `id` became
15809    /// `"abc"` after round-trip.
15810    #[test]
15811    fn file_media_roundtrip_id_with_spaces() {
15812        let adf_json = r#"{
15813          "version": 1,
15814          "type": "doc",
15815          "content": [
15816            {
15817              "type": "mediaSingle",
15818              "attrs": {"layout": "center"},
15819              "content": [
15820                {
15821                  "type": "media",
15822                  "attrs": {
15823                    "type": "file",
15824                    "id": "abc 123 def 456",
15825                    "collection": "my-collection",
15826                    "width": 800,
15827                    "height": 600
15828                  }
15829                }
15830              ]
15831            }
15832          ]
15833        }"#;
15834        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15835        let md = adf_to_markdown(&doc).unwrap();
15836        assert!(
15837            md.contains(r#"id="abc 123 def 456""#),
15838            "id with spaces should be quoted in JFM, got:\n{md}"
15839        );
15840        let rt = markdown_to_adf(&md).unwrap();
15841        let expected: serde_json::Value = serde_json::from_str(adf_json).unwrap();
15842        let actual = serde_json::to_value(&rt).unwrap();
15843        assert_eq!(
15844            actual, expected,
15845            "space-containing id must round-trip; md was:\n{md}"
15846        );
15847    }
15848
15849    /// Space-containing `collection` values must round-trip.
15850    #[test]
15851    fn file_media_roundtrip_collection_with_spaces() {
15852        let adf_json = r#"{
15853          "version": 1,
15854          "type": "doc",
15855          "content": [
15856            {
15857              "type": "mediaSingle",
15858              "attrs": {"layout": "center"},
15859              "content": [
15860                {
15861                  "type": "media",
15862                  "attrs": {
15863                    "type": "file",
15864                    "id": "abc-123",
15865                    "collection": "my collection with spaces"
15866                  }
15867                }
15868              ]
15869            }
15870          ]
15871        }"#;
15872        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15873        let md = adf_to_markdown(&doc).unwrap();
15874        let rt = markdown_to_adf(&md).unwrap();
15875        let media = &rt.content[0].content.as_ref().unwrap()[0];
15876        assert_eq!(
15877            media.attrs.as_ref().unwrap()["collection"],
15878            "my collection with spaces"
15879        );
15880    }
15881
15882    /// Space-containing `occurrenceKey` values must round-trip.
15883    #[test]
15884    fn file_media_roundtrip_occurrence_key_with_spaces() {
15885        let adf_json = r#"{
15886          "version": 1,
15887          "type": "doc",
15888          "content": [
15889            {
15890              "type": "mediaSingle",
15891              "attrs": {"layout": "center"},
15892              "content": [
15893                {
15894                  "type": "media",
15895                  "attrs": {
15896                    "type": "file",
15897                    "id": "x",
15898                    "collection": "y",
15899                    "occurrenceKey": "key with spaces"
15900                  }
15901                }
15902              ]
15903            }
15904          ]
15905        }"#;
15906        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15907        let md = adf_to_markdown(&doc).unwrap();
15908        let rt = markdown_to_adf(&md).unwrap();
15909        let media = &rt.content[0].content.as_ref().unwrap()[0];
15910        assert_eq!(
15911            media.attrs.as_ref().unwrap()["occurrenceKey"],
15912            "key with spaces"
15913        );
15914    }
15915
15916    /// Values with embedded `"` must be escape-quoted and round-trip.
15917    #[test]
15918    fn file_media_roundtrip_id_with_quote_char() {
15919        let adf_json = r#"{
15920          "version": 1,
15921          "type": "doc",
15922          "content": [
15923            {
15924              "type": "mediaSingle",
15925              "attrs": {"layout": "center"},
15926              "content": [
15927                {
15928                  "type": "media",
15929                  "attrs": {
15930                    "type": "file",
15931                    "id": "a\"b\"c",
15932                    "collection": "col"
15933                  }
15934                }
15935              ]
15936            }
15937          ]
15938        }"#;
15939        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15940        let md = adf_to_markdown(&doc).unwrap();
15941        let rt = markdown_to_adf(&md).unwrap();
15942        let media = &rt.content[0].content.as_ref().unwrap()[0];
15943        assert_eq!(media.attrs.as_ref().unwrap()["id"], "a\"b\"c");
15944    }
15945
15946    /// `mediaInline` string attrs with spaces must round-trip (parallel fix
15947    /// for the inline-directive rendering path).
15948    #[test]
15949    fn media_inline_roundtrip_id_with_spaces() {
15950        let adf_json = r#"{
15951          "version": 1,
15952          "type": "doc",
15953          "content": [
15954            {
15955              "type": "paragraph",
15956              "content": [
15957                {"type": "text", "text": "before "},
15958                {
15959                  "type": "mediaInline",
15960                  "attrs": {
15961                    "type": "file",
15962                    "id": "a b c",
15963                    "collection": "my col"
15964                  }
15965                },
15966                {"type": "text", "text": " after"}
15967              ]
15968            }
15969          ]
15970        }"#;
15971        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15972        let md = adf_to_markdown(&doc).unwrap();
15973        let rt = markdown_to_adf(&md).unwrap();
15974        let inline = &rt.content[0].content.as_ref().unwrap()[1];
15975        assert_eq!(inline.node_type, "mediaInline");
15976        let attrs = inline.attrs.as_ref().unwrap();
15977        assert_eq!(attrs["id"], "a b c");
15978        assert_eq!(attrs["collection"], "my col");
15979    }
15980
15981    /// Issue #550: `occurrenceKey` attribute is a standard ADF media attr and
15982    /// must be preserved through ADF→JFM→ADF roundtrip.
15983    #[test]
15984    fn file_media_roundtrip_preserves_occurrence_key() {
15985        let adf_json = r#"{
15986          "version": 1,
15987          "type": "doc",
15988          "content": [
15989            {
15990              "type": "mediaSingle",
15991              "attrs": {"layout": "center"},
15992              "content": [
15993                {
15994                  "type": "media",
15995                  "attrs": {
15996                    "type": "file",
15997                    "id": "abc-123",
15998                    "collection": "my-collection",
15999                    "occurrenceKey": "unique-key-xyz",
16000                    "width": 200,
16001                    "height": 100
16002                  }
16003                }
16004              ]
16005            }
16006          ]
16007        }"#;
16008        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16009        let md = adf_to_markdown(&doc).unwrap();
16010        assert!(
16011            md.contains("occurrenceKey=unique-key-xyz"),
16012            "expected occurrenceKey in markdown, got: {md}"
16013        );
16014        let rt = markdown_to_adf(&md).unwrap();
16015        let media = &rt.content[0].content.as_ref().unwrap()[0];
16016        let attrs = media.attrs.as_ref().unwrap();
16017        assert_eq!(attrs["occurrenceKey"], "unique-key-xyz");
16018        assert_eq!(attrs["type"], "file");
16019        assert_eq!(attrs["id"], "abc-123");
16020        assert_eq!(attrs["collection"], "my-collection");
16021    }
16022
16023    // ── mediaSingle caption tests (issue #470) ──────────────────────────
16024
16025    #[test]
16026    fn media_single_caption_adf_to_markdown() {
16027        let adf_doc = serde_json::json!({
16028            "type": "doc",
16029            "version": 1,
16030            "content": [{
16031                "type": "mediaSingle",
16032                "attrs": {"layout": "center", "width": 400, "widthType": "pixel"},
16033                "content": [
16034                    {
16035                        "type": "media",
16036                        "attrs": {
16037                            "id": "aabbccdd-1234-5678-abcd-aabbccdd1234",
16038                            "type": "file",
16039                            "collection": "contentId-123456",
16040                            "width": 800,
16041                            "height": 600
16042                        }
16043                    },
16044                    {
16045                        "type": "caption",
16046                        "content": [{"type": "text", "text": "An image caption here"}]
16047                    }
16048                ]
16049            }]
16050        });
16051        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16052        let md = adf_to_markdown(&doc).unwrap();
16053        assert!(
16054            md.contains(":::caption"),
16055            "expected :::caption in markdown, got: {md}"
16056        );
16057        assert!(
16058            md.contains("An image caption here"),
16059            "expected caption text in markdown, got: {md}"
16060        );
16061    }
16062
16063    #[test]
16064    fn media_single_caption_markdown_to_adf() {
16065        let md = "![Screenshot](){type=file id=abc-123 collection=contentId-456 height=600 width=800}\n:::caption\nAn image caption here\n:::\n";
16066        let doc = markdown_to_adf(md).unwrap();
16067        let ms = &doc.content[0];
16068        assert_eq!(ms.node_type, "mediaSingle");
16069        let content = ms.content.as_ref().unwrap();
16070        assert_eq!(content.len(), 2, "expected media + caption children");
16071        assert_eq!(content[0].node_type, "media");
16072        assert_eq!(content[1].node_type, "caption");
16073        let caption_content = content[1].content.as_ref().unwrap();
16074        assert_eq!(
16075            caption_content[0].text.as_deref(),
16076            Some("An image caption here")
16077        );
16078    }
16079
16080    #[test]
16081    fn media_single_caption_round_trip() {
16082        // Full round-trip: ADF → JFM → ADF preserves caption
16083        let adf_doc = serde_json::json!({
16084            "type": "doc",
16085            "version": 1,
16086            "content": [{
16087                "type": "mediaSingle",
16088                "attrs": {"layout": "center", "width": 400, "widthType": "pixel"},
16089                "content": [
16090                    {
16091                        "type": "media",
16092                        "attrs": {
16093                            "id": "aabbccdd-1234-5678-abcd-aabbccdd1234",
16094                            "type": "file",
16095                            "collection": "contentId-123456",
16096                            "width": 800,
16097                            "height": 600
16098                        }
16099                    },
16100                    {
16101                        "type": "caption",
16102                        "content": [{"type": "text", "text": "An image caption here"}]
16103                    }
16104                ]
16105            }]
16106        });
16107        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16108        let md = adf_to_markdown(&doc).unwrap();
16109        let doc2 = markdown_to_adf(&md).unwrap();
16110        let ms = &doc2.content[0];
16111        assert_eq!(ms.node_type, "mediaSingle");
16112        let content = ms.content.as_ref().unwrap();
16113        assert_eq!(
16114            content.len(),
16115            2,
16116            "expected media + caption after round-trip"
16117        );
16118        assert_eq!(content[1].node_type, "caption");
16119        let caption_content = content[1].content.as_ref().unwrap();
16120        assert_eq!(
16121            caption_content[0].text.as_deref(),
16122            Some("An image caption here")
16123        );
16124    }
16125
16126    #[test]
16127    fn media_single_caption_with_inline_marks() {
16128        let adf_doc = serde_json::json!({
16129            "type": "doc",
16130            "version": 1,
16131            "content": [{
16132                "type": "mediaSingle",
16133                "attrs": {"layout": "center"},
16134                "content": [
16135                    {
16136                        "type": "media",
16137                        "attrs": {"type": "external", "url": "https://example.com/img.png"}
16138                    },
16139                    {
16140                        "type": "caption",
16141                        "content": [
16142                            {"type": "text", "text": "A "},
16143                            {"type": "text", "text": "bold", "marks": [{"type": "strong"}]},
16144                            {"type": "text", "text": " caption"}
16145                        ]
16146                    }
16147                ]
16148            }]
16149        });
16150        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16151        let md = adf_to_markdown(&doc).unwrap();
16152        assert!(
16153            md.contains("**bold**"),
16154            "expected bold in caption, got: {md}"
16155        );
16156
16157        let doc2 = markdown_to_adf(&md).unwrap();
16158        let content = doc2.content[0].content.as_ref().unwrap();
16159        assert_eq!(content.len(), 2, "expected media + caption");
16160        assert_eq!(content[1].node_type, "caption");
16161        let caption_inlines = content[1].content.as_ref().unwrap();
16162        let bold_node = caption_inlines
16163            .iter()
16164            .find(|n| n.text.as_deref() == Some("bold"))
16165            .unwrap();
16166        let marks = bold_node.marks.as_ref().unwrap();
16167        assert_eq!(marks[0].mark_type, "strong");
16168    }
16169
16170    #[test]
16171    fn media_single_no_caption_unaffected() {
16172        // Existing mediaSingle without caption should be unaffected
16173        let adf_doc = serde_json::json!({
16174            "type": "doc",
16175            "version": 1,
16176            "content": [{
16177                "type": "mediaSingle",
16178                "attrs": {"layout": "center"},
16179                "content": [{
16180                    "type": "media",
16181                    "attrs": {"type": "external", "url": "https://example.com/img.png"}
16182                }]
16183            }]
16184        });
16185        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16186        let md = adf_to_markdown(&doc).unwrap();
16187        assert!(
16188            !md.contains(":::caption"),
16189            "should not emit caption when none present"
16190        );
16191        let doc2 = markdown_to_adf(&md).unwrap();
16192        let content = doc2.content[0].content.as_ref().unwrap();
16193        assert_eq!(content.len(), 1, "should only have media child");
16194        assert_eq!(content[0].node_type, "media");
16195    }
16196
16197    #[test]
16198    fn media_single_empty_caption_round_trip() {
16199        // Caption node with no content should still round-trip
16200        let adf_doc = serde_json::json!({
16201            "type": "doc",
16202            "version": 1,
16203            "content": [{
16204                "type": "mediaSingle",
16205                "attrs": {"layout": "center"},
16206                "content": [
16207                    {
16208                        "type": "media",
16209                        "attrs": {"type": "external", "url": "https://example.com/img.png"}
16210                    },
16211                    {
16212                        "type": "caption"
16213                    }
16214                ]
16215            }]
16216        });
16217        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16218        let md = adf_to_markdown(&doc).unwrap();
16219        assert!(
16220            md.contains(":::caption"),
16221            "expected :::caption even for empty caption, got: {md}"
16222        );
16223        assert!(
16224            md.contains(":::\n"),
16225            "expected closing ::: fence, got: {md}"
16226        );
16227    }
16228
16229    #[test]
16230    fn media_single_external_caption_round_trip() {
16231        // External image with caption round-trips
16232        let md = "![alt](https://example.com/img.png)\n:::caption\nImage description\n:::\n";
16233        let doc = markdown_to_adf(md).unwrap();
16234        let ms = &doc.content[0];
16235        assert_eq!(ms.node_type, "mediaSingle");
16236        let content = ms.content.as_ref().unwrap();
16237        assert_eq!(content.len(), 2);
16238        assert_eq!(content[0].node_type, "media");
16239        assert_eq!(content[1].node_type, "caption");
16240
16241        let md2 = adf_to_markdown(&doc).unwrap();
16242        let doc2 = markdown_to_adf(&md2).unwrap();
16243        let content2 = doc2.content[0].content.as_ref().unwrap();
16244        assert_eq!(content2.len(), 2);
16245        assert_eq!(content2[1].node_type, "caption");
16246        let caption_text = content2[1].content.as_ref().unwrap();
16247        assert_eq!(caption_text[0].text.as_deref(), Some("Image description"));
16248    }
16249
16250    // ── mediaSingle caption localId tests (issue #524) ─────────────────
16251
16252    #[test]
16253    fn media_single_caption_localid_roundtrip() {
16254        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"}]}]}]}"#;
16255        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16256        let md = adf_to_markdown(&doc).unwrap();
16257        assert!(
16258            md.contains("localId=9da8c2104471"),
16259            "caption localId should appear in markdown: {md}"
16260        );
16261        let rt = markdown_to_adf(&md).unwrap();
16262        let content = rt.content[0].content.as_ref().unwrap();
16263        let caption = &content[1];
16264        assert_eq!(caption.node_type, "caption");
16265        assert_eq!(
16266            caption.attrs.as_ref().unwrap()["localId"],
16267            "9da8c2104471",
16268            "caption localId should round-trip"
16269        );
16270    }
16271
16272    #[test]
16273    fn media_single_caption_without_localid() {
16274        let md = "![Screenshot](){type=file id=abc-123 collection=contentId-456 height=600 width=800}\n:::caption\nPlain caption\n:::\n";
16275        let doc = markdown_to_adf(md).unwrap();
16276        let caption = &doc.content[0].content.as_ref().unwrap()[1];
16277        assert_eq!(caption.node_type, "caption");
16278        assert!(
16279            caption.attrs.is_none(),
16280            "caption without localId should not gain attrs"
16281        );
16282        let md2 = adf_to_markdown(&doc).unwrap();
16283        assert!(
16284            !md2.contains("localId"),
16285            "no localId should appear in output: {md2}"
16286        );
16287    }
16288
16289    #[test]
16290    fn media_single_caption_localid_stripped_when_option_set() {
16291        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"}]}]}]}"#;
16292        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16293        let opts = RenderOptions {
16294            strip_local_ids: true,
16295            ..Default::default()
16296        };
16297        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
16298        assert!(!md.contains("localId"), "localId should be stripped: {md}");
16299    }
16300
16301    #[test]
16302    fn table_width_roundtrip() {
16303        // ADF table with width attribute
16304        let adf_doc = serde_json::json!({
16305            "type": "doc",
16306            "version": 1,
16307            "content": [{
16308                "type": "table",
16309                "attrs": {"layout": "default", "width": 760.0},
16310                "content": [{
16311                    "type": "tableRow",
16312                    "content": [{
16313                        "type": "tableHeader",
16314                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "H"}]}]
16315                    }]
16316                }]
16317            }]
16318        });
16319        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16320        let md = adf_to_markdown(&doc).unwrap();
16321        assert!(
16322            md.contains("width=760.0"),
16323            "expected width=760.0 in markdown (float preserved), got: {md}"
16324        );
16325        // Round-trip back to ADF
16326        let doc2 = markdown_to_adf(&md).unwrap();
16327        let table = &doc2.content[0];
16328        assert_eq!(table.node_type, "table");
16329        let table_attrs = table.attrs.as_ref().unwrap();
16330        assert_eq!(table_attrs["width"], 760.0);
16331        assert!(
16332            table_attrs["width"].is_f64(),
16333            "expected float width to be preserved as f64, got: {:?}",
16334            table_attrs["width"]
16335        );
16336    }
16337
16338    #[test]
16339    fn table_integer_width_roundtrip_preserves_integer() {
16340        // Issue #577: Integer width in ADF must survive roundtrip without being
16341        // coerced to a float.
16342        let adf_doc = serde_json::json!({
16343            "type": "doc",
16344            "version": 1,
16345            "content": [{
16346                "type": "table",
16347                "attrs": {
16348                    "isNumberColumnEnabled": false,
16349                    "layout": "center",
16350                    "localId": "abc-123",
16351                    "width": 1420
16352                },
16353                "content": [{
16354                    "type": "tableRow",
16355                    "content": [{
16356                        "type": "tableCell",
16357                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Cell"}]}]
16358                    }]
16359                }]
16360            }]
16361        });
16362        let doc: crate::atlassian::adf::AdfDocument =
16363            serde_json::from_value(adf_doc.clone()).unwrap();
16364        let md = adf_to_markdown(&doc).unwrap();
16365        assert!(
16366            md.contains("width=1420"),
16367            "expected width=1420 in markdown, got: {md}"
16368        );
16369        assert!(
16370            !md.contains("width=1420.0"),
16371            "integer width should not be rendered with decimal: {md}"
16372        );
16373
16374        let doc2 = markdown_to_adf(&md).unwrap();
16375        let table = &doc2.content[0];
16376        assert_eq!(table.node_type, "table");
16377        let table_attrs = table.attrs.as_ref().unwrap();
16378        assert_eq!(table_attrs["width"], 1420);
16379        assert!(
16380            table_attrs["width"].is_u64() || table_attrs["width"].is_i64(),
16381            "width should remain an integer, got: {:?}",
16382            table_attrs["width"]
16383        );
16384        assert!(
16385            !table_attrs["width"].is_f64(),
16386            "width should not be a float, got: {:?}",
16387            table_attrs["width"]
16388        );
16389
16390        // Full byte-fidelity: re-serialized ADF should match original JSON.
16391        let roundtripped = serde_json::to_value(&doc2).unwrap();
16392        let orig_width = &adf_doc["content"][0]["attrs"]["width"];
16393        let rt_width = &roundtripped["content"][0]["attrs"]["width"];
16394        assert_eq!(
16395            orig_width, rt_width,
16396            "width value must roundtrip byte-for-byte"
16397        );
16398    }
16399
16400    #[test]
16401    fn table_fractional_width_roundtrip() {
16402        // Fractional float widths should also roundtrip faithfully.
16403        let adf_doc = serde_json::json!({
16404            "type": "doc",
16405            "version": 1,
16406            "content": [{
16407                "type": "table",
16408                "attrs": {"layout": "default", "width": 760.5},
16409                "content": [{
16410                    "type": "tableRow",
16411                    "content": [{
16412                        "type": "tableHeader",
16413                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "H"}]}]
16414                    }]
16415                }]
16416            }]
16417        });
16418        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16419        let md = adf_to_markdown(&doc).unwrap();
16420        assert!(
16421            md.contains("width=760.5"),
16422            "expected width=760.5 in markdown, got: {md}"
16423        );
16424        let doc2 = markdown_to_adf(&md).unwrap();
16425        let table_attrs = doc2.content[0].attrs.as_ref().unwrap();
16426        assert_eq!(table_attrs["width"], 760.5);
16427        assert!(table_attrs["width"].is_f64());
16428    }
16429
16430    #[test]
16431    fn pipe_table_integer_width_roundtrip() {
16432        // Exercises the try_table() attrs-on-next-line parsing path.
16433        let md = "| A | B |\n|---|---|\n| 1 | 2 |\n{layout=default width=1420}\n";
16434        let doc = markdown_to_adf(md).unwrap();
16435        let table = &doc.content[0];
16436        assert_eq!(table.node_type, "table");
16437        let attrs = table.attrs.as_ref().unwrap();
16438        assert_eq!(attrs["width"], 1420);
16439        assert!(
16440            attrs["width"].is_u64() || attrs["width"].is_i64(),
16441            "pipe-table width must stay integer, got: {:?}",
16442            attrs["width"]
16443        );
16444    }
16445
16446    #[test]
16447    fn file_media_width_type_roundtrip() {
16448        // mediaSingle with widthType:pixel should survive round-trip
16449        let adf_doc = serde_json::json!({
16450            "type": "doc",
16451            "version": 1,
16452            "content": [{
16453                "type": "mediaSingle",
16454                "attrs": {"layout": "center", "width": 312, "widthType": "pixel"},
16455                "content": [{
16456                    "type": "media",
16457                    "attrs": {
16458                        "type": "file",
16459                        "id": "abc123",
16460                        "collection": "contentId-999",
16461                        "height": 56,
16462                        "width": 312
16463                    }
16464                }]
16465            }]
16466        });
16467        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16468        let md = adf_to_markdown(&doc).unwrap();
16469        assert!(
16470            md.contains("widthType=pixel"),
16471            "expected widthType=pixel in markdown, got: {md}"
16472        );
16473        let doc2 = markdown_to_adf(&md).unwrap();
16474        let ms = &doc2.content[0];
16475        let ms_attrs = ms.attrs.as_ref().unwrap();
16476        assert_eq!(ms_attrs["widthType"], "pixel");
16477        assert_eq!(ms_attrs["width"], 312);
16478    }
16479
16480    #[test]
16481    fn file_media_pixel_width_above_100_passes_validation() {
16482        // Issue #1037: confluence_read emits pixel widths well above 100 for
16483        // editor-sized images (here width=900). Lowering carries width=900
16484        // widthType=pixel onto the mediaSingle, which is valid ADF — the
16485        // upstream schema's pixel branch is unbounded. A read→write round-trip
16486        // must clear the write-path validator, not be rejected as out of range
16487        // (the original failure: "value 900 is outside the allowed range
16488        // [0, 100]"). Unlike `file_media_width_type_roundtrip`, this asserts on
16489        // the validator, which the conversion-only round-trip never exercises.
16490        let md = "![alt](){type=file id=11111111-2222-3333-4444-555555555555 collection=contentId-98765 height=904 width=900 mediaWidth=900 widthType=pixel}\n";
16491        let adf = markdown_to_adf(md).unwrap();
16492
16493        let ms_attrs = adf.content[0].attrs.as_ref().unwrap();
16494        assert_eq!(ms_attrs["width"], 900);
16495        assert_eq!(ms_attrs["widthType"], "pixel");
16496
16497        crate::atlassian::adf_validated::validate(&adf).unwrap();
16498    }
16499
16500    #[test]
16501    fn file_media_mode_roundtrip() {
16502        // mediaSingle with mode attr should survive round-trip (issue #431)
16503        let adf_doc = serde_json::json!({
16504            "type": "doc",
16505            "version": 1,
16506            "content": [{
16507                "type": "mediaSingle",
16508                "attrs": {"layout": "wide", "mode": "wide", "width": 1200},
16509                "content": [{
16510                    "type": "media",
16511                    "attrs": {
16512                        "type": "file",
16513                        "id": "abc123",
16514                        "collection": "test",
16515                        "width": 1200,
16516                        "height": 600
16517                    }
16518                }]
16519            }]
16520        });
16521        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16522        let md = adf_to_markdown(&doc).unwrap();
16523        assert!(
16524            md.contains("mode=wide"),
16525            "expected mode=wide in markdown, got: {md}"
16526        );
16527        let doc2 = markdown_to_adf(&md).unwrap();
16528        let ms = &doc2.content[0];
16529        let ms_attrs = ms.attrs.as_ref().unwrap();
16530        assert_eq!(ms_attrs["mode"], "wide");
16531        assert_eq!(ms_attrs["layout"], "wide");
16532        assert_eq!(ms_attrs["width"], 1200);
16533    }
16534
16535    #[test]
16536    fn external_media_mode_roundtrip() {
16537        // External mediaSingle with mode attr should survive round-trip (issue #431)
16538        let adf_doc = serde_json::json!({
16539            "type": "doc",
16540            "version": 1,
16541            "content": [{
16542                "type": "mediaSingle",
16543                "attrs": {"layout": "wide", "mode": "wide"},
16544                "content": [{
16545                    "type": "media",
16546                    "attrs": {
16547                        "type": "external",
16548                        "url": "https://example.com/image.png"
16549                    }
16550                }]
16551            }]
16552        });
16553        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16554        let md = adf_to_markdown(&doc).unwrap();
16555        assert!(
16556            md.contains("mode=wide"),
16557            "expected mode=wide in markdown, got: {md}"
16558        );
16559        let doc2 = markdown_to_adf(&md).unwrap();
16560        let ms = &doc2.content[0];
16561        let ms_attrs = ms.attrs.as_ref().unwrap();
16562        assert_eq!(ms_attrs["mode"], "wide");
16563        assert_eq!(ms_attrs["layout"], "wide");
16564    }
16565
16566    #[test]
16567    fn media_mode_only_roundtrip() {
16568        // mediaSingle with mode but default layout should still preserve mode (issue #431)
16569        let adf_doc = serde_json::json!({
16570            "type": "doc",
16571            "version": 1,
16572            "content": [{
16573                "type": "mediaSingle",
16574                "attrs": {"layout": "center", "mode": "default"},
16575                "content": [{
16576                    "type": "media",
16577                    "attrs": {
16578                        "type": "external",
16579                        "url": "https://example.com/image.png"
16580                    }
16581                }]
16582            }]
16583        });
16584        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16585        let md = adf_to_markdown(&doc).unwrap();
16586        assert!(
16587            md.contains("mode=default"),
16588            "expected mode=default in markdown, got: {md}"
16589        );
16590        let doc2 = markdown_to_adf(&md).unwrap();
16591        let ms = &doc2.content[0];
16592        let ms_attrs = ms.attrs.as_ref().unwrap();
16593        assert_eq!(ms_attrs["mode"], "default");
16594    }
16595
16596    #[test]
16597    fn file_media_hex_localid_roundtrip() {
16598        // Issue #432: short hex localId (non-UUID) must survive round-trip
16599        let adf_doc = serde_json::json!({
16600            "type": "doc",
16601            "version": 1,
16602            "content": [{
16603                "type": "mediaSingle",
16604                "attrs": {"layout": "wide", "width": 1200, "widthType": "pixel"},
16605                "content": [{
16606                    "type": "media",
16607                    "attrs": {
16608                        "type": "file",
16609                        "id": "eb7a9c3b-314e-4458-8200-4b22b67b122e",
16610                        "collection": "contentId-123",
16611                        "height": 484,
16612                        "width": 915,
16613                        "alt": "image.png",
16614                        "localId": "0e79f58ac382"
16615                    }
16616                }]
16617            }]
16618        });
16619        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16620        let md = adf_to_markdown(&doc).unwrap();
16621        assert!(
16622            md.contains("localId=0e79f58ac382"),
16623            "expected localId=0e79f58ac382 in markdown, got: {md}"
16624        );
16625        let doc2 = markdown_to_adf(&md).unwrap();
16626        let ms = &doc2.content[0];
16627        let media = &ms.content.as_ref().unwrap()[0];
16628        let attrs = media.attrs.as_ref().unwrap();
16629        assert_eq!(attrs["localId"], "0e79f58ac382");
16630    }
16631
16632    #[test]
16633    fn file_media_uuid_localid_roundtrip() {
16634        // UUID-format localId must also survive round-trip
16635        let adf_doc = serde_json::json!({
16636            "type": "doc",
16637            "version": 1,
16638            "content": [{
16639                "type": "mediaSingle",
16640                "attrs": {"layout": "center"},
16641                "content": [{
16642                    "type": "media",
16643                    "attrs": {
16644                        "type": "file",
16645                        "id": "abc-123",
16646                        "collection": "contentId-456",
16647                        "height": 100,
16648                        "width": 200,
16649                        "localId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
16650                    }
16651                }]
16652            }]
16653        });
16654        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16655        let md = adf_to_markdown(&doc).unwrap();
16656        assert!(
16657            md.contains("localId=a1b2c3d4-e5f6-7890-abcd-ef1234567890"),
16658            "expected UUID localId in markdown, got: {md}"
16659        );
16660        let doc2 = markdown_to_adf(&md).unwrap();
16661        let media = &doc2.content[0].content.as_ref().unwrap()[0];
16662        let attrs = media.attrs.as_ref().unwrap();
16663        assert_eq!(attrs["localId"], "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
16664    }
16665
16666    #[test]
16667    fn file_media_null_uuid_localid_stripped() {
16668        // Null UUID localId should be stripped (consistent with other node types)
16669        let adf_doc = serde_json::json!({
16670            "type": "doc",
16671            "version": 1,
16672            "content": [{
16673                "type": "mediaSingle",
16674                "attrs": {"layout": "center"},
16675                "content": [{
16676                    "type": "media",
16677                    "attrs": {
16678                        "type": "file",
16679                        "id": "abc-123",
16680                        "collection": "contentId-456",
16681                        "height": 100,
16682                        "width": 200,
16683                        "localId": "00000000-0000-0000-0000-000000000000"
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="),
16692            "null UUID localId should be stripped, got: {md}"
16693        );
16694    }
16695
16696    #[test]
16697    fn file_media_localid_stripped_when_option_set() {
16698        // localId should be stripped when strip_local_ids option is enabled
16699        let adf_doc = serde_json::json!({
16700            "type": "doc",
16701            "version": 1,
16702            "content": [{
16703                "type": "mediaSingle",
16704                "attrs": {"layout": "center"},
16705                "content": [{
16706                    "type": "media",
16707                    "attrs": {
16708                        "type": "file",
16709                        "id": "abc-123",
16710                        "collection": "contentId-456",
16711                        "height": 100,
16712                        "width": 200,
16713                        "localId": "0e79f58ac382"
16714                    }
16715                }]
16716            }]
16717        });
16718        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16719        let opts = RenderOptions {
16720            strip_local_ids: true,
16721            ..Default::default()
16722        };
16723        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
16724        assert!(
16725            !md.contains("localId="),
16726            "localId should be stripped with strip_local_ids, got: {md}"
16727        );
16728    }
16729
16730    #[test]
16731    fn external_media_localid_roundtrip() {
16732        // localId on external media nodes must also survive round-trip
16733        let adf_doc = serde_json::json!({
16734            "type": "doc",
16735            "version": 1,
16736            "content": [{
16737                "type": "mediaSingle",
16738                "attrs": {"layout": "center"},
16739                "content": [{
16740                    "type": "media",
16741                    "attrs": {
16742                        "type": "external",
16743                        "url": "https://example.com/image.png",
16744                        "alt": "test",
16745                        "localId": "deadbeef1234"
16746                    }
16747                }]
16748            }]
16749        });
16750        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16751        let md = adf_to_markdown(&doc).unwrap();
16752        assert!(
16753            md.contains("localId=deadbeef1234"),
16754            "expected localId in markdown for external media, got: {md}"
16755        );
16756        let doc2 = markdown_to_adf(&md).unwrap();
16757        let media = &doc2.content[0].content.as_ref().unwrap()[0];
16758        let attrs = media.attrs.as_ref().unwrap();
16759        assert_eq!(attrs["localId"], "deadbeef1234");
16760    }
16761
16762    #[test]
16763    fn bracket_in_text_not_parsed_as_link() {
16764        // "[Task] some text (Link)" — the [Task] must NOT be treated as a link anchor
16765        let md = ":check_mark: [Task] Unable to start trial ([Link](https://example.com/link))";
16766        let doc = markdown_to_adf(md).unwrap();
16767        let para = &doc.content[0];
16768        assert_eq!(para.node_type, "paragraph");
16769        let content = para.content.as_ref().unwrap();
16770        // Find the text node containing "[Task]"
16771        let text_nodes: Vec<_> = content.iter().filter(|n| n.node_type == "text").collect();
16772        let has_task_bracket = text_nodes
16773            .iter()
16774            .any(|n| n.text.as_deref().unwrap_or("").contains("[Task]"));
16775        assert!(
16776            has_task_bracket,
16777            "expected [Task] in plain text, nodes: {content:?}"
16778        );
16779        // Also verify the (Link) is a proper link
16780        let link_nodes: Vec<_> = content
16781            .iter()
16782            .filter(|n| {
16783                n.marks
16784                    .as_ref()
16785                    .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "link"))
16786            })
16787            .collect();
16788        assert!(!link_nodes.is_empty(), "expected a link node");
16789        assert_eq!(
16790            link_nodes[0].text.as_deref(),
16791            Some("Link"),
16792            "link text should be 'Link'"
16793        );
16794    }
16795
16796    #[test]
16797    fn empty_paragraph_roundtrip() {
16798        // An empty ADF paragraph node should survive a round-trip through markdown
16799        let mut adf_in = AdfDocument::new();
16800        adf_in.content = vec![
16801            AdfNode::paragraph(vec![AdfNode::text("before")]),
16802            AdfNode::paragraph(vec![]),
16803            AdfNode::paragraph(vec![AdfNode::text("after")]),
16804        ];
16805        let md = adf_to_markdown(&adf_in).unwrap();
16806        let adf_out = markdown_to_adf(&md).unwrap();
16807        assert_eq!(
16808            adf_out.content.len(),
16809            3,
16810            "should have 3 blocks, markdown:\n{md}"
16811        );
16812        assert_eq!(adf_out.content[0].node_type, "paragraph");
16813        assert_eq!(adf_out.content[1].node_type, "paragraph");
16814        assert!(
16815            adf_out.content[1].content.is_none(),
16816            "middle paragraph should be empty"
16817        );
16818        assert_eq!(adf_out.content[2].node_type, "paragraph");
16819    }
16820
16821    #[test]
16822    fn nbsp_paragraph_roundtrip() {
16823        // Issue #411: paragraph with only NBSP should survive round-trip
16824        let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}]}";
16825        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16826        let md = adf_to_markdown(&doc).unwrap();
16827        assert!(
16828            md.contains("::paragraph["),
16829            "NBSP paragraph should use directive form: {md}"
16830        );
16831        let rt = markdown_to_adf(&md).unwrap();
16832        assert_eq!(rt.content.len(), 1, "should have 1 block");
16833        assert_eq!(rt.content[0].node_type, "paragraph");
16834        let text = rt.content[0].content.as_ref().unwrap()[0]
16835            .text
16836            .as_deref()
16837            .unwrap_or("");
16838        assert_eq!(text, "\u{00a0}", "NBSP should survive round-trip");
16839    }
16840
16841    #[test]
16842    fn nbsp_in_nested_expand_roundtrip() {
16843        // Issue #411 real-world case: NBSP paragraph inside nestedExpand
16844        let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"nestedExpand\",\"attrs\":{\"title\":\"Section\"},\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}]}]}";
16845        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16846        let md = adf_to_markdown(&doc).unwrap();
16847        let rt = markdown_to_adf(&md).unwrap();
16848        let ne = &rt.content[0];
16849        assert_eq!(ne.node_type, "nestedExpand");
16850        let inner = ne.content.as_ref().unwrap();
16851        assert_eq!(inner.len(), 1, "should have 1 inner block");
16852        assert_eq!(inner[0].node_type, "paragraph");
16853        let content = inner[0].content.as_ref().unwrap();
16854        assert!(!content.is_empty(), "paragraph should not be empty");
16855        let text = content[0].text.as_deref().unwrap_or("");
16856        assert_eq!(text, "\u{00a0}", "NBSP should survive in nestedExpand");
16857    }
16858
16859    #[test]
16860    fn nbsp_followed_by_content() {
16861        // NBSP paragraph followed by regular content should not interfere
16862        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\"}]}]}";
16863        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16864        let md = adf_to_markdown(&doc).unwrap();
16865        let rt = markdown_to_adf(&md).unwrap();
16866        assert!(rt.content.len() >= 2, "should have at least 2 blocks");
16867        // The second block should be a paragraph with "after"
16868        let after_para = rt.content.iter().find(|n| {
16869            n.node_type == "paragraph"
16870                && n.content
16871                    .as_ref()
16872                    .and_then(|c| c.first())
16873                    .and_then(|n| n.text.as_deref())
16874                    .is_some_and(|t| t.contains("after"))
16875        });
16876        assert!(after_para.is_some(), "should have paragraph with 'after'");
16877    }
16878
16879    #[test]
16880    fn nbsp_paragraph_with_marks_survives() {
16881        // NBSP with bold marks renders as `** **` which contains non-whitespace
16882        // chars and thus doesn't need the directive form — it round-trips naturally
16883        let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\",\"marks\":[{\"type\":\"strong\"}]}]}]}";
16884        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16885        let md = adf_to_markdown(&doc).unwrap();
16886        assert!(md.contains("**"), "should have bold markers: {md}");
16887        let rt = markdown_to_adf(&md).unwrap();
16888        let content = rt.content[0].content.as_ref().unwrap();
16889        assert!(!content.is_empty(), "should preserve content");
16890    }
16891
16892    #[test]
16893    fn regular_paragraph_unchanged() {
16894        // Regression guard: normal paragraphs should NOT use directive form
16895        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}"#;
16896        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16897        let md = adf_to_markdown(&doc).unwrap();
16898        assert!(
16899            !md.contains("::paragraph"),
16900            "regular paragraphs should not use directive form: {md}"
16901        );
16902        assert!(md.contains("hello"));
16903    }
16904
16905    #[test]
16906    fn paragraph_directive_with_content_parsed() {
16907        // ::paragraph[content] should parse to a paragraph with inline nodes
16908        let md = "::paragraph[\u{00a0}]\n";
16909        let doc = markdown_to_adf(md).unwrap();
16910        assert_eq!(doc.content.len(), 1);
16911        assert_eq!(doc.content[0].node_type, "paragraph");
16912        let content = doc.content[0].content.as_ref().unwrap();
16913        assert!(!content.is_empty(), "should have inline content");
16914        assert_eq!(content[0].text.as_deref().unwrap(), "\u{00a0}");
16915    }
16916
16917    #[test]
16918    fn nbsp_paragraph_in_list_item_with_nested_list() {
16919        // Issue #448: NBSP paragraph content lost inside listItem with nested bulletList
16920        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"}]}]}]}]}]}]}"#;
16921        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16922        let md = adf_to_markdown(&doc).unwrap();
16923        let rt = markdown_to_adf(&md).unwrap();
16924        let list = &rt.content[0];
16925        assert_eq!(list.node_type, "bulletList");
16926        let item = &list.content.as_ref().unwrap()[0];
16927        let item_content = item.content.as_ref().unwrap();
16928        assert_eq!(
16929            item_content.len(),
16930            2,
16931            "listItem should have paragraph + nested list, got: {item_content:?}"
16932        );
16933        let para = &item_content[0];
16934        assert_eq!(para.node_type, "paragraph");
16935        let para_content = para
16936            .content
16937            .as_ref()
16938            .expect("paragraph should have content");
16939        assert!(
16940            !para_content.is_empty(),
16941            "NBSP paragraph content should not be empty"
16942        );
16943        assert_eq!(
16944            para_content[0].text.as_deref().unwrap(),
16945            "\u{00a0}",
16946            "NBSP should survive round-trip inside listItem"
16947        );
16948    }
16949
16950    #[test]
16951    fn nbsp_paragraph_in_list_item_with_local_ids() {
16952        // Issue #448: NBSP paragraph with localIds inside listItem with nested list
16953        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"}]}]}]}]}]}]}"#;
16954        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16955        let md = adf_to_markdown(&doc).unwrap();
16956        let rt = markdown_to_adf(&md).unwrap();
16957        let list = &rt.content[0];
16958        let item = &list.content.as_ref().unwrap()[0];
16959        // Check listItem localId
16960        assert_eq!(
16961            item.attrs.as_ref().unwrap()["localId"],
16962            "li-001",
16963            "listItem localId should survive"
16964        );
16965        let item_content = item.content.as_ref().unwrap();
16966        assert_eq!(item_content.len(), 2);
16967        // Check paragraph localId and NBSP content
16968        let para = &item_content[0];
16969        assert_eq!(
16970            para.attrs.as_ref().unwrap()["localId"],
16971            "p-001",
16972            "paragraph localId should survive"
16973        );
16974        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
16975        assert_eq!(text, "\u{00a0}", "NBSP should survive with localIds");
16976    }
16977
16978    #[test]
16979    fn nbsp_paragraph_in_list_item_without_nested_list() {
16980        // NBSP paragraph in a simple listItem (no nested list)
16981        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"}]}]}]}]}"#;
16982        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16983        let md = adf_to_markdown(&doc).unwrap();
16984        let rt = markdown_to_adf(&md).unwrap();
16985        let list = &rt.content[0];
16986        let item = &list.content.as_ref().unwrap()[0];
16987        let para = &item.content.as_ref().unwrap()[0];
16988        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
16989        assert_eq!(text, "\u{00a0}", "NBSP should survive in simple list item");
16990    }
16991
16992    #[test]
16993    fn nbsp_paragraph_in_ordered_list_item_with_nested_list() {
16994        // NBSP paragraph in ordered listItem with nested bulletList
16995        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"}]}]}]}]}]}]}"#;
16996        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16997        let md = adf_to_markdown(&doc).unwrap();
16998        let rt = markdown_to_adf(&md).unwrap();
16999        let list = &rt.content[0];
17000        let item = &list.content.as_ref().unwrap()[0];
17001        let item_content = item.content.as_ref().unwrap();
17002        assert_eq!(item_content.len(), 2);
17003        let para = &item_content[0];
17004        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
17005        assert_eq!(text, "\u{00a0}", "NBSP should survive in ordered list item");
17006    }
17007
17008    #[test]
17009    fn list_item_leading_space_preserved() {
17010        // Leading space in list item text must not be stripped
17011        let md = "- hello world\n- - text";
17012        let doc = markdown_to_adf(md).unwrap();
17013        let list = &doc.content[0];
17014        assert_eq!(list.node_type, "bulletList");
17015        let items = list.content.as_ref().unwrap();
17016        // First item: "hello world" (no leading space, unchanged)
17017        let first_para = &items[0].content.as_ref().unwrap()[0];
17018        let first_text = &first_para.content.as_ref().unwrap()[0];
17019        assert_eq!(first_text.text.as_deref(), Some("hello world"));
17020    }
17021
17022    #[test]
17023    fn list_item_leading_space_not_stripped() {
17024        // When the markdown list item content has a leading space (e.g. " :emoji:"),
17025        // that space must reach parse_inline as-is.
17026        let md = "-  leading space text";
17027        let doc = markdown_to_adf(md).unwrap();
17028        let list = &doc.content[0];
17029        let items = list.content.as_ref().unwrap();
17030        let para = &items[0].content.as_ref().unwrap()[0];
17031        let text_node = &para.content.as_ref().unwrap()[0];
17032        // After "- " (2 chars), trim_end keeps the leading space: " leading space text"
17033        assert_eq!(
17034            text_node.text.as_deref(),
17035            Some(" leading space text"),
17036            "leading space should be preserved"
17037        );
17038    }
17039
17040    // ── Nested container directive tests ───────────────────────────
17041
17042    // ── hardBreak in table cell tests ────────────────────────────
17043
17044    #[test]
17045    fn hardbreak_in_cell_uses_directive_table() {
17046        // A table cell with a hardBreak should NOT use pipe syntax
17047        // because the newline would break the row
17048        let adf = AdfDocument {
17049            version: 1,
17050            doc_type: "doc".to_string(),
17051            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17052                AdfNode::table_cell(vec![AdfNode::paragraph(vec![
17053                    AdfNode::text("before"),
17054                    AdfNode::hard_break(),
17055                    AdfNode::text("after"),
17056                ])]),
17057            ])])],
17058        };
17059        let md = adf_to_markdown(&adf).unwrap();
17060        // Should render as directive table, not pipe table
17061        assert!(
17062            md.contains(":::td") || md.contains("::::table"),
17063            "Table with hardBreak should use directive form, got:\n{md}"
17064        );
17065        assert!(
17066            !md.contains("| before"),
17067            "Should NOT use pipe syntax with hardBreak"
17068        );
17069    }
17070
17071    #[test]
17072    fn hardbreak_in_cell_roundtrips() {
17073        // Verify the directive table form preserves the hardBreak on round-trip
17074        let adf = AdfDocument {
17075            version: 1,
17076            doc_type: "doc".to_string(),
17077            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17078                AdfNode::table_cell(vec![AdfNode::paragraph(vec![
17079                    AdfNode::text("line one"),
17080                    AdfNode::hard_break(),
17081                    AdfNode::text("line two"),
17082                ])]),
17083            ])])],
17084        };
17085        let md = adf_to_markdown(&adf).unwrap();
17086        let roundtripped = markdown_to_adf(&md).unwrap();
17087
17088        // Should still have one table with one row with one cell
17089        assert_eq!(roundtripped.content.len(), 1);
17090        assert_eq!(roundtripped.content[0].node_type, "table");
17091        let rows = roundtripped.content[0].content.as_ref().unwrap();
17092        assert_eq!(
17093            rows.len(),
17094            1,
17095            "Should have exactly 1 row, got {}",
17096            rows.len()
17097        );
17098    }
17099
17100    #[test]
17101    fn hardbreak_in_paragraph_roundtrips() {
17102        // Issue #373: hardBreak absorbed into preceding text node
17103        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
17104          {"type":"text","text":"line one"},
17105          {"type":"hardBreak"},
17106          {"type":"text","text":"line two"}
17107        ]}]}"#;
17108        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17109        let md = adf_to_markdown(&doc).unwrap();
17110        let round_tripped = markdown_to_adf(&md).unwrap();
17111        let inlines = round_tripped.content[0].content.as_ref().unwrap();
17112        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17113        assert_eq!(
17114            types,
17115            vec!["text", "hardBreak", "text"],
17116            "hardBreak should be preserved, got: {types:?}"
17117        );
17118        assert_eq!(inlines[0].text.as_deref(), Some("line one"));
17119        assert_eq!(inlines[2].text.as_deref(), Some("line two"));
17120    }
17121
17122    #[test]
17123    fn consecutive_hardbreaks_in_paragraph_roundtrip() {
17124        // Issue #410: consecutive hardBreak nodes collapsed on round-trip
17125        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
17126          {"type":"text","text":"before"},
17127          {"type":"hardBreak"},
17128          {"type":"hardBreak"},
17129          {"type":"text","text":"after"}
17130        ]}]}"#;
17131        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17132        let md = adf_to_markdown(&doc).unwrap();
17133        let round_tripped = markdown_to_adf(&md).unwrap();
17134        assert_eq!(
17135            round_tripped.content.len(),
17136            1,
17137            "Should remain a single paragraph, got {} blocks",
17138            round_tripped.content.len()
17139        );
17140        let inlines = round_tripped.content[0].content.as_ref().unwrap();
17141        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17142        assert_eq!(
17143            types,
17144            vec!["text", "hardBreak", "hardBreak", "text"],
17145            "Both hardBreaks should be preserved, got: {types:?}"
17146        );
17147        assert_eq!(inlines[0].text.as_deref(), Some("before"));
17148        assert_eq!(inlines[3].text.as_deref(), Some("after"));
17149    }
17150
17151    #[test]
17152    fn hardbreak_only_paragraph_roundtrips() {
17153        // Issue #410: paragraph whose only content is a hardBreak is dropped
17154        let adf_json = r#"{"version":1,"type":"doc","content":[
17155          {"type":"paragraph","content":[{"type":"hardBreak"}]}
17156        ]}"#;
17157        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17158        let md = adf_to_markdown(&doc).unwrap();
17159        let round_tripped = markdown_to_adf(&md).unwrap();
17160        assert_eq!(
17161            round_tripped.content.len(),
17162            1,
17163            "Paragraph should not be dropped, got {} blocks",
17164            round_tripped.content.len()
17165        );
17166        let inlines = round_tripped.content[0].content.as_ref().unwrap();
17167        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17168        assert_eq!(
17169            types,
17170            vec!["hardBreak"],
17171            "hardBreak-only paragraph should preserve its content, got: {types:?}"
17172        );
17173    }
17174
17175    #[test]
17176    fn issue_410_full_reproducer_roundtrips() {
17177        // Full reproducer from issue #410: consecutive hardBreaks + hardBreak-only paragraph
17178        let adf_json = r#"{"version":1,"type":"doc","content":[
17179          {"type":"paragraph","content":[
17180            {"type":"text","text":"before"},
17181            {"type":"hardBreak"},
17182            {"type":"hardBreak"},
17183            {"type":"text","text":"after"}
17184          ]},
17185          {"type":"paragraph","content":[
17186            {"type":"hardBreak"}
17187          ]}
17188        ]}"#;
17189        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17190        let md = adf_to_markdown(&doc).unwrap();
17191        let round_tripped = markdown_to_adf(&md).unwrap();
17192        assert_eq!(
17193            round_tripped.content.len(),
17194            2,
17195            "Should have exactly 2 paragraphs, got {}",
17196            round_tripped.content.len()
17197        );
17198        // First paragraph: text, hardBreak, hardBreak, text
17199        let p1 = round_tripped.content[0].content.as_ref().unwrap();
17200        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
17201        assert_eq!(types1, vec!["text", "hardBreak", "hardBreak", "text"]);
17202        // Second paragraph: hardBreak only
17203        let p2 = round_tripped.content[1].content.as_ref().unwrap();
17204        let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
17205        assert_eq!(types2, vec!["hardBreak"]);
17206    }
17207
17208    #[test]
17209    fn trailing_space_hardbreak_still_parsed() {
17210        // Backward compatibility: trailing-space hardBreak (old JFM format) still parses
17211        let md = "line one  \nline two\n";
17212        let doc = markdown_to_adf(md).unwrap();
17213        let inlines = doc.content[0].content.as_ref().unwrap();
17214        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17215        assert_eq!(
17216            types,
17217            vec!["text", "hardBreak", "text"],
17218            "Trailing-space hardBreak should still parse, got: {types:?}"
17219        );
17220    }
17221
17222    #[test]
17223    fn trailing_hardbreak_at_end_of_paragraph_roundtrips() {
17224        // A paragraph ending with a hardBreak (no text after it)
17225        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
17226          {"type":"text","text":"text"},
17227          {"type":"hardBreak"}
17228        ]}]}"#;
17229        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17230        let md = adf_to_markdown(&doc).unwrap();
17231        let round_tripped = markdown_to_adf(&md).unwrap();
17232        let inlines = round_tripped.content[0].content.as_ref().unwrap();
17233        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17234        assert_eq!(
17235            types,
17236            vec!["text", "hardBreak"],
17237            "Trailing hardBreak should be preserved, got: {types:?}"
17238        );
17239    }
17240
17241    #[test]
17242    #[test]
17243    fn table_with_header_row_uses_pipe_syntax() {
17244        // A table with tableHeader in the first row should use pipe syntax
17245        let adf = AdfDocument {
17246            version: 1,
17247            doc_type: "doc".to_string(),
17248            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17249                AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("header cell")])]),
17250            ])])],
17251        };
17252        let md = adf_to_markdown(&adf).unwrap();
17253        assert!(
17254            md.contains("| header cell |"),
17255            "Table with header row should use pipe syntax, got:\n{md}"
17256        );
17257    }
17258
17259    #[test]
17260    fn table_without_header_row_uses_directive_syntax() {
17261        // Issue #392: tableCell-only first row must use directive syntax
17262        // to avoid converting tableCell → tableHeader on round-trip
17263        let adf = AdfDocument {
17264            version: 1,
17265            doc_type: "doc".to_string(),
17266            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17267                AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("simple cell")])]),
17268            ])])],
17269        };
17270        let md = adf_to_markdown(&adf).unwrap();
17271        assert!(
17272            md.contains("::::table"),
17273            "Table without header row should use directive syntax, got:\n{md}"
17274        );
17275    }
17276
17277    #[test]
17278    fn tablecell_first_row_preserved_on_roundtrip() {
17279        // Issue #392: tableCell in first row round-trips as tableHeader
17280        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{},"content":[
17281          {"type":"tableRow","content":[
17282            {"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"row1 cell"}]}]}
17283          ]},
17284          {"type":"tableRow","content":[
17285            {"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"row2 cell"}]}]}
17286          ]}
17287        ]}]}"#;
17288        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17289        let md = adf_to_markdown(&doc).unwrap();
17290        let round_tripped = markdown_to_adf(&md).unwrap();
17291        let rows = round_tripped.content[0].content.as_ref().unwrap();
17292        let row0_cell = &rows[0].content.as_ref().unwrap()[0];
17293        assert_eq!(
17294            row0_cell.node_type, "tableCell",
17295            "first row cell should remain tableCell, got: {}",
17296            row0_cell.node_type
17297        );
17298        let row1_cell = &rows[1].content.as_ref().unwrap()[0];
17299        assert_eq!(row1_cell.node_type, "tableCell");
17300    }
17301
17302    #[test]
17303    fn mixed_header_and_cell_first_row_uses_pipe() {
17304        // A first row with at least one tableHeader qualifies for pipe syntax
17305        let adf = AdfDocument {
17306            version: 1,
17307            doc_type: "doc".to_string(),
17308            content: vec![AdfNode::table(vec![
17309                AdfNode::table_row(vec![
17310                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
17311                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
17312                ]),
17313                AdfNode::table_row(vec![
17314                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C1")])]),
17315                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
17316                ]),
17317            ])],
17318        };
17319        let md = adf_to_markdown(&adf).unwrap();
17320        assert!(
17321            md.contains("| H1 |"),
17322            "Table with header first row should use pipe syntax, got:\n{md}"
17323        );
17324        assert!(!md.contains("::::table"), "should not use directive syntax");
17325    }
17326
17327    // ── Issue #579: pipes in pipe-table cells ─────────────────────
17328
17329    #[test]
17330    fn render_pipe_table_escapes_pipe_in_code_span_cell() {
17331        // A code-marked text node with a literal `|` in a pipe-table cell
17332        // must emit `\|` so the column separator is unambiguous.
17333        let adf = AdfDocument {
17334            version: 1,
17335            doc_type: "doc".to_string(),
17336            content: vec![AdfNode::table(vec![
17337                AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
17338                    AdfNode::text("Header"),
17339                ])])]),
17340                AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
17341                    AdfNode::text_with_marks("a|b", vec![AdfMark::code()]),
17342                ])])]),
17343            ])],
17344        };
17345        let md = adf_to_markdown(&adf).unwrap();
17346        assert!(
17347            md.contains(r"`a\|b`"),
17348            "Pipe inside code span must be escaped, got:\n{md}"
17349        );
17350    }
17351
17352    #[test]
17353    fn render_pipe_table_escapes_pipe_in_plain_text_cell() {
17354        let adf = AdfDocument {
17355            version: 1,
17356            doc_type: "doc".to_string(),
17357            content: vec![AdfNode::table(vec![
17358                AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
17359                    AdfNode::text("Header"),
17360                ])])]),
17361                AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
17362                    AdfNode::text("x|y"),
17363                ])])]),
17364            ])],
17365        };
17366        let md = adf_to_markdown(&adf).unwrap();
17367        assert!(
17368            md.contains(r"x\|y"),
17369            "Pipe inside plain-text cell must be escaped, got:\n{md}"
17370        );
17371    }
17372
17373    #[test]
17374    fn code_span_with_pipe_in_table_cell_roundtrips() {
17375        // Issue #579 reproducer: code span containing `|` in a pipe-table cell.
17376        let adf_json = r#"{
17377            "version": 1,
17378            "type": "doc",
17379            "content": [{
17380                "type": "table",
17381                "attrs": {"isNumberColumnEnabled": false, "layout": "default", "localId": "abc-789"},
17382                "content": [
17383                    {"type": "tableRow", "content": [
17384                        {"type": "tableHeader", "attrs": {}, "content": [
17385                            {"type": "paragraph", "content": [{"type": "text", "text": "Before"}]}
17386                        ]},
17387                        {"type": "tableHeader", "attrs": {}, "content": [
17388                            {"type": "paragraph", "content": [{"type": "text", "text": "After"}]}
17389                        ]}
17390                    ]},
17391                    {"type": "tableRow", "content": [
17392                        {"type": "tableCell", "attrs": {}, "content": [
17393                            {"type": "paragraph", "content": [
17394                                {"type": "text", "text": "parse(json).extract[T]", "marks": [{"type": "code"}]}
17395                            ]}
17396                        ]},
17397                        {"type": "tableCell", "attrs": {}, "content": [
17398                            {"type": "paragraph", "content": [
17399                                {"type": "text", "text": "parser.decode[T|json]", "marks": [{"type": "code"}]}
17400                            ]}
17401                        ]}
17402                    ]}
17403                ]
17404            }]
17405        }"#;
17406        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17407        let md = adf_to_markdown(&doc).unwrap();
17408        let round_tripped = markdown_to_adf(&md).unwrap();
17409
17410        let rows = round_tripped.content[0].content.as_ref().unwrap();
17411        assert_eq!(
17412            rows.len(),
17413            2,
17414            "Table should have 2 rows, got: {}",
17415            rows.len()
17416        );
17417
17418        let body_row = rows[1].content.as_ref().unwrap();
17419        assert_eq!(
17420            body_row.len(),
17421            2,
17422            "Body row should have 2 cells (not split by the pipe), got: {}",
17423            body_row.len()
17424        );
17425
17426        let second_cell = &body_row[1];
17427        let para = second_cell.content.as_ref().unwrap().first().unwrap();
17428        let inlines = para.content.as_ref().unwrap();
17429        assert_eq!(inlines.len(), 1, "Cell should have a single text node");
17430        assert_eq!(
17431            inlines[0].text.as_deref(),
17432            Some("parser.decode[T|json]"),
17433            "Code-span text must be preserved with literal pipe"
17434        );
17435        let marks = inlines[0]
17436            .marks
17437            .as_ref()
17438            .expect("code mark must be preserved");
17439        assert!(
17440            marks.iter().any(|m| m.mark_type == "code"),
17441            "text node should carry the code mark"
17442        );
17443    }
17444
17445    #[test]
17446    fn plain_text_pipe_in_table_cell_roundtrips() {
17447        // Plain text with `|` in a pipe-table cell should also survive.
17448        let adf = AdfDocument {
17449            version: 1,
17450            doc_type: "doc".to_string(),
17451            content: vec![AdfNode::table(vec![
17452                AdfNode::table_row(vec![
17453                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
17454                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
17455                ]),
17456                AdfNode::table_row(vec![
17457                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("a|b")])]),
17458                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("c")])]),
17459                ]),
17460            ])],
17461        };
17462        let md = adf_to_markdown(&adf).unwrap();
17463        let round_tripped = markdown_to_adf(&md).unwrap();
17464        let rows = round_tripped.content[0].content.as_ref().unwrap();
17465        let body_row = rows[1].content.as_ref().unwrap();
17466        assert_eq!(
17467            body_row.len(),
17468            2,
17469            "Body row should keep 2 cells, got: {}",
17470            body_row.len()
17471        );
17472        let first_cell_text = body_row[0].content.as_ref().unwrap()[0]
17473            .content
17474            .as_ref()
17475            .unwrap()[0]
17476            .text
17477            .as_deref();
17478        assert_eq!(first_cell_text, Some("a|b"));
17479    }
17480
17481    #[test]
17482    fn cell_contains_hard_break_true() {
17483        let para = AdfNode::paragraph(vec![
17484            AdfNode::text("a"),
17485            AdfNode::hard_break(),
17486            AdfNode::text("b"),
17487        ]);
17488        assert!(cell_contains_hard_break(&para));
17489    }
17490
17491    #[test]
17492    fn cell_contains_hard_break_false() {
17493        let para = AdfNode::paragraph(vec![AdfNode::text("no break here")]);
17494        assert!(!cell_contains_hard_break(&para));
17495    }
17496
17497    #[test]
17498    fn cell_contains_hard_break_empty() {
17499        let para = AdfNode::paragraph(vec![]);
17500        assert!(!cell_contains_hard_break(&para));
17501    }
17502
17503    // ── Multi-paragraph container tests ──────────────────────────
17504
17505    #[test]
17506    fn multi_paragraph_panel_roundtrips() {
17507        let adf = AdfDocument {
17508            version: 1,
17509            doc_type: "doc".to_string(),
17510            content: vec![AdfNode {
17511                node_type: "panel".to_string(),
17512                attrs: Some(serde_json::json!({"panelType": "info"})),
17513                content: Some(vec![
17514                    AdfNode::paragraph(vec![AdfNode::text("First paragraph.")]),
17515                    AdfNode::paragraph(vec![AdfNode::text("Second paragraph.")]),
17516                ]),
17517                text: None,
17518                marks: None,
17519                local_id: None,
17520                parameters: None,
17521            }],
17522        };
17523
17524        let md = adf_to_markdown(&adf).unwrap();
17525        // Should have blank line between paragraphs inside the panel
17526        assert!(
17527            md.contains("First paragraph.\n\nSecond paragraph."),
17528            "Panel should have blank line between paragraphs, got:\n{md}"
17529        );
17530
17531        // Round-trip should preserve two separate paragraphs
17532        let roundtripped = markdown_to_adf(&md).unwrap();
17533        assert_eq!(roundtripped.content.len(), 1);
17534        assert_eq!(roundtripped.content[0].node_type, "panel");
17535        let panel_content = roundtripped.content[0].content.as_ref().unwrap();
17536        assert_eq!(
17537            panel_content.len(),
17538            2,
17539            "Panel should have 2 paragraphs after round-trip, got {}",
17540            panel_content.len()
17541        );
17542    }
17543
17544    #[test]
17545    fn multi_paragraph_expand_roundtrips() {
17546        let adf = AdfDocument {
17547            version: 1,
17548            doc_type: "doc".to_string(),
17549            content: vec![AdfNode {
17550                node_type: "expand".to_string(),
17551                attrs: Some(serde_json::json!({"title": "Details"})),
17552                content: Some(vec![
17553                    AdfNode::paragraph(vec![AdfNode::text("Para one.")]),
17554                    AdfNode::paragraph(vec![AdfNode::text("Para two.")]),
17555                ]),
17556                text: None,
17557                marks: None,
17558                local_id: None,
17559                parameters: None,
17560            }],
17561        };
17562
17563        let md = adf_to_markdown(&adf).unwrap();
17564        let roundtripped = markdown_to_adf(&md).unwrap();
17565        let expand_content = roundtripped.content[0].content.as_ref().unwrap();
17566        assert_eq!(
17567            expand_content.len(),
17568            2,
17569            "Expand should have 2 paragraphs after round-trip, got {}",
17570            expand_content.len()
17571        );
17572    }
17573
17574    #[test]
17575    fn consecutive_nested_expands_in_table_cell_roundtrip() {
17576        let cell_content = vec![
17577            AdfNode {
17578                node_type: "nestedExpand".to_string(),
17579                attrs: Some(serde_json::json!({"title": "First"})),
17580                content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("item 1")])]),
17581                text: None,
17582                marks: None,
17583                local_id: None,
17584                parameters: None,
17585            },
17586            AdfNode {
17587                node_type: "nestedExpand".to_string(),
17588                attrs: Some(serde_json::json!({"title": "Second"})),
17589                content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("item 2")])]),
17590                text: None,
17591                marks: None,
17592                local_id: None,
17593                parameters: None,
17594            },
17595        ];
17596        let adf = AdfDocument {
17597            version: 1,
17598            doc_type: "doc".to_string(),
17599            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17600                AdfNode::table_cell(cell_content),
17601            ])])],
17602        };
17603
17604        let md = adf_to_markdown(&adf).unwrap();
17605        assert!(
17606            md.contains(":::\n\n:::nested-expand"),
17607            "Should have blank line between consecutive nested-expands in cell, got:\n{md}"
17608        );
17609
17610        let rt = markdown_to_adf(&md).unwrap();
17611        let cell = &rt.content[0].content.as_ref().unwrap()[0]
17612            .content
17613            .as_ref()
17614            .unwrap()[0];
17615        let cell_nodes = cell.content.as_ref().unwrap();
17616        let expand_count = cell_nodes
17617            .iter()
17618            .filter(|n| n.node_type == "nestedExpand")
17619            .count();
17620        assert_eq!(
17621            expand_count, 2,
17622            "Both nested-expands should survive round-trip, got {expand_count}"
17623        );
17624    }
17625
17626    #[test]
17627    fn multi_paragraph_in_table_cell_roundtrip() {
17628        // Two paragraphs inside a directive table cell should survive round-trip
17629        let adf = AdfDocument {
17630            version: 1,
17631            doc_type: "doc".to_string(),
17632            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17633                AdfNode::table_cell(vec![
17634                    AdfNode::paragraph(vec![AdfNode::text("Para one.")]),
17635                    AdfNode::paragraph(vec![AdfNode::text("Para two.")]),
17636                ]),
17637            ])])],
17638        };
17639
17640        let md = adf_to_markdown(&adf).unwrap();
17641        assert!(
17642            md.contains("Para one.\n\nPara two."),
17643            "Should have blank line between paragraphs in cell, got:\n{md}"
17644        );
17645
17646        let rt = markdown_to_adf(&md).unwrap();
17647        let cell = &rt.content[0].content.as_ref().unwrap()[0]
17648            .content
17649            .as_ref()
17650            .unwrap()[0];
17651        let para_count = cell
17652            .content
17653            .as_ref()
17654            .unwrap()
17655            .iter()
17656            .filter(|n| n.node_type == "paragraph")
17657            .count();
17658        assert_eq!(para_count, 2, "Both paragraphs should survive round-trip");
17659    }
17660
17661    #[test]
17662    fn panel_inside_table_cell_roundtrip() {
17663        // A panel inside a directive table cell
17664        let adf = AdfDocument {
17665            version: 1,
17666            doc_type: "doc".to_string(),
17667            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17668                AdfNode::table_cell(vec![
17669                    AdfNode::paragraph(vec![AdfNode::text("Before panel.")]),
17670                    AdfNode {
17671                        node_type: "panel".to_string(),
17672                        attrs: Some(serde_json::json!({"panelType": "info"})),
17673                        content: Some(vec![AdfNode::paragraph(vec![AdfNode::text(
17674                            "Panel content",
17675                        )])]),
17676                        text: None,
17677                        marks: None,
17678                        local_id: None,
17679                        parameters: None,
17680                    },
17681                ]),
17682            ])])],
17683        };
17684
17685        let md = adf_to_markdown(&adf).unwrap();
17686        assert!(
17687            md.contains(":::panel"),
17688            "Should contain panel directive, got:\n{md}"
17689        );
17690
17691        let rt = markdown_to_adf(&md).unwrap();
17692        let cell = &rt.content[0].content.as_ref().unwrap()[0]
17693            .content
17694            .as_ref()
17695            .unwrap()[0];
17696        let has_panel = cell
17697            .content
17698            .as_ref()
17699            .unwrap()
17700            .iter()
17701            .any(|n| n.node_type == "panel");
17702        assert!(has_panel, "Panel should survive round-trip in table cell");
17703    }
17704
17705    #[test]
17706    fn three_consecutive_expands_in_table_cell() {
17707        let make_expand = |title: &str| AdfNode {
17708            node_type: "nestedExpand".to_string(),
17709            attrs: Some(serde_json::json!({"title": title})),
17710            content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("content")])]),
17711            text: None,
17712            marks: None,
17713            local_id: None,
17714            parameters: None,
17715        };
17716        let adf = AdfDocument {
17717            version: 1,
17718            doc_type: "doc".to_string(),
17719            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17720                AdfNode::table_cell(vec![
17721                    make_expand("First"),
17722                    make_expand("Second"),
17723                    make_expand("Third"),
17724                ]),
17725            ])])],
17726        };
17727
17728        let md = adf_to_markdown(&adf).unwrap();
17729        let rt = markdown_to_adf(&md).unwrap();
17730        let cell = &rt.content[0].content.as_ref().unwrap()[0]
17731            .content
17732            .as_ref()
17733            .unwrap()[0];
17734        let expand_count = cell
17735            .content
17736            .as_ref()
17737            .unwrap()
17738            .iter()
17739            .filter(|n| n.node_type == "nestedExpand")
17740            .count();
17741        assert_eq!(expand_count, 3, "All 3 expands should survive round-trip");
17742    }
17743
17744    // ── Nested container directive tests ───────────────────────────
17745
17746    #[test]
17747    fn nested_expand_inside_panel() {
17748        // Issue #714: the converter still produces panel→expand at the AST
17749        // level (this is what users may type), but the document fails ADF
17750        // schema validation, surfacing an actionable error before the API
17751        // call rather than an opaque Confluence HTTP 500.
17752        let md = ":::panel{type=info}\n:::expand{title=\"Details\"}\nHidden content\n:::\nMore panel content\n:::";
17753        let adf = markdown_to_adf(md).unwrap();
17754
17755        let err = crate::atlassian::adf_validated::validate(&adf).unwrap_err();
17756        assert!(err.violations.iter().any(|v| matches!(
17757            v,
17758            crate::atlassian::adf_schema::AdfSchemaViolation::DisallowedChild {
17759                parent_type, child_type, ..
17760            } if parent_type == "panel" && child_type == "expand"
17761        )));
17762    }
17763
17764    #[test]
17765    fn nested_expand_inside_table_cell() {
17766        // Issue #714: tableCell → expand is a Confluence content-model
17767        // violation. Table cells require `nestedExpand` instead. Validation
17768        // catches this before the API call.
17769        let md = "::::table\n:::tr\n:::td\n:::expand{title=\"Details\"}\nExpand content\n:::\n:::\n:::\n::::";
17770        let adf = markdown_to_adf(md).unwrap();
17771
17772        let err = crate::atlassian::adf_validated::validate(&adf).unwrap_err();
17773        assert!(err.violations.iter().any(|v| matches!(
17774            v,
17775            crate::atlassian::adf_schema::AdfSchemaViolation::DisallowedChild {
17776                parent_type, child_type, ..
17777            } if parent_type == "tableCell" && child_type == "expand"
17778        )));
17779    }
17780
17781    #[test]
17782    fn nested_expand_inside_layout_column() {
17783        // Issue #714 sanity check: `expand` inside a `layoutColumn` is
17784        // legitimate per the ADF schema and must NOT trigger validation.
17785        // Note: layoutSection requires 2..=3 columns per #733's quantifier
17786        // checks, so the markdown declares two columns.
17787        let md = ":::layout\n:::column{width=50}\n:::expand{title=\"Col Expand\"}\nExpanded\n:::\n:::\n:::column{width=50}\nFiller paragraph.\n:::\n:::";
17788        let adf = markdown_to_adf(md).unwrap();
17789
17790        assert_eq!(adf.content.len(), 1);
17791        assert_eq!(adf.content[0].node_type, "layoutSection");
17792
17793        let columns = adf.content[0].content.as_ref().unwrap();
17794        assert_eq!(columns.len(), 2);
17795        let col_content = columns[0].content.as_ref().unwrap();
17796        assert!(
17797            col_content.iter().any(|n| n.node_type == "expand"),
17798            "Column should contain an expand node, got: {:?}",
17799            col_content.iter().map(|n| &n.node_type).collect::<Vec<_>>()
17800        );
17801
17802        // Validation must not flag this legitimate nesting.
17803        crate::atlassian::adf_validated::validate(&adf).unwrap();
17804    }
17805
17806    #[test]
17807    fn expand_localid_in_directive_attrs() {
17808        // Issue #412: localId should be in directive attrs, not trailing text
17809        let adf_json = r#"{"version":1,"type":"doc","content":[
17810          {"type":"expand","attrs":{"localId":"exp-001","title":"Details"},"content":[
17811            {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17812          ]}
17813        ]}"#;
17814        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17815        let md = adf_to_markdown(&doc).unwrap();
17816        assert!(
17817            md.contains("localId=exp-001"),
17818            "should contain localId: {md}"
17819        );
17820        assert!(
17821            md.contains(":::expand{"),
17822            "should have expand directive with attrs: {md}"
17823        );
17824        assert!(
17825            !md.contains(":::\n{localId="),
17826            "localId should NOT be trailing: {md}"
17827        );
17828    }
17829
17830    #[test]
17831    fn expand_localid_roundtrip() {
17832        let adf_json = r#"{"version":1,"type":"doc","content":[
17833          {"type":"expand","attrs":{"localId":"exp-001","title":"Details"},"content":[
17834            {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17835          ]}
17836        ]}"#;
17837        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17838        let md = adf_to_markdown(&doc).unwrap();
17839        let rt = markdown_to_adf(&md).unwrap();
17840        let expand = &rt.content[0];
17841        assert_eq!(expand.node_type, "expand");
17842        assert_eq!(
17843            expand.local_id.as_deref(),
17844            Some("exp-001"),
17845            "expand localId should survive round-trip"
17846        );
17847        assert_eq!(
17848            expand.attrs.as_ref().unwrap()["title"],
17849            "Details",
17850            "expand title should survive round-trip"
17851        );
17852    }
17853
17854    #[test]
17855    fn nested_expand_localid_roundtrip() {
17856        let adf_json = r#"{"version":1,"type":"doc","content":[
17857          {"type":"nestedExpand","attrs":{"localId":"ne-001","title":"S"},"content":[
17858            {"type":"paragraph","content":[{"type":"text","text":"content"}]}
17859          ]}
17860        ]}"#;
17861        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17862        let md = adf_to_markdown(&doc).unwrap();
17863        assert!(
17864            md.contains(":::nested-expand{"),
17865            "should have directive: {md}"
17866        );
17867        assert!(md.contains("localId=ne-001"), "should have localId: {md}");
17868        let rt = markdown_to_adf(&md).unwrap();
17869        let ne = &rt.content[0];
17870        assert_eq!(ne.node_type, "nestedExpand");
17871        assert_eq!(ne.local_id.as_deref(), Some("ne-001"));
17872    }
17873
17874    #[test]
17875    fn nested_expand_localid_followed_by_content() {
17876        // Issue #412 reproducer: localId must not leak into following paragraph
17877        let adf_json = "{\
17878            \"version\":1,\"type\":\"doc\",\"content\":[\
17879              {\"type\":\"nestedExpand\",\"attrs\":{\"localId\":\"exp-001\",\"title\":\"S\"},\"content\":[\
17880                {\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}\
17881              ]},\
17882              {\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"after\"}]}\
17883            ]}";
17884        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17885        let md = adf_to_markdown(&doc).unwrap();
17886        let rt = markdown_to_adf(&md).unwrap();
17887        // nestedExpand should have localId
17888        let ne = &rt.content[0];
17889        assert_eq!(ne.node_type, "nestedExpand");
17890        assert_eq!(
17891            ne.local_id.as_deref(),
17892            Some("exp-001"),
17893            "nestedExpand should preserve localId"
17894        );
17895        // Following paragraph should contain "after", not "{localId=...}"
17896        let para = &rt.content[1];
17897        assert_eq!(para.node_type, "paragraph");
17898        let text = para.content.as_ref().unwrap()[0]
17899            .text
17900            .as_deref()
17901            .unwrap_or("");
17902        assert!(
17903            !text.contains("localId"),
17904            "following paragraph should not contain localId: {text}"
17905        );
17906        assert!(
17907            text.contains("after"),
17908            "following paragraph should contain 'after': {text}"
17909        );
17910    }
17911
17912    #[test]
17913    fn expand_localid_without_title() {
17914        let adf_json = r#"{"version":1,"type":"doc","content":[
17915          {"type":"expand","attrs":{"localId":"exp-002"},"content":[
17916            {"type":"paragraph","content":[{"type":"text","text":"no title"}]}
17917          ]}
17918        ]}"#;
17919        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17920        let md = adf_to_markdown(&doc).unwrap();
17921        assert!(
17922            md.contains(":::expand{localId=exp-002}"),
17923            "should have localId without title: {md}"
17924        );
17925        let rt = markdown_to_adf(&md).unwrap();
17926        assert_eq!(rt.content[0].local_id.as_deref(), Some("exp-002"));
17927    }
17928
17929    #[test]
17930    fn expand_localid_stripped() {
17931        let adf_json = r#"{"version":1,"type":"doc","content":[
17932          {"type":"expand","attrs":{"localId":"exp-001","title":"X"},"content":[
17933            {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17934          ]}
17935        ]}"#;
17936        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17937        let opts = RenderOptions {
17938            strip_local_ids: true,
17939        };
17940        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
17941        assert!(!md.contains("localId"), "localId should be stripped: {md}");
17942        assert!(
17943            md.contains(":::expand{title=\"X\"}"),
17944            "title should remain: {md}"
17945        );
17946    }
17947
17948    // ── Issue #444: top-level localId and parameters on expand ──
17949
17950    #[test]
17951    fn expand_top_level_localid_roundtrip() {
17952        // localId as a top-level field (not inside attrs) should survive round-trip
17953        let adf_json = r#"{"version":1,"type":"doc","content":[
17954          {"type":"expand","attrs":{"title":"My Section"},"localId":"abc-123","content":[
17955            {"type":"paragraph","content":[{"type":"text","text":"hello"}]}
17956          ]}
17957        ]}"#;
17958        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17959        assert_eq!(doc.content[0].local_id.as_deref(), Some("abc-123"));
17960        let md = adf_to_markdown(&doc).unwrap();
17961        assert!(
17962            md.contains("localId=abc-123"),
17963            "JFM should contain localId: {md}"
17964        );
17965        let rt = markdown_to_adf(&md).unwrap();
17966        let expand = &rt.content[0];
17967        assert_eq!(expand.node_type, "expand");
17968        assert_eq!(expand.local_id.as_deref(), Some("abc-123"));
17969        assert_eq!(
17970            expand.attrs.as_ref().unwrap()["title"],
17971            "My Section",
17972            "title should survive round-trip"
17973        );
17974    }
17975
17976    #[test]
17977    fn expand_parameters_roundtrip() {
17978        // parameters (macroMetadata) should survive round-trip
17979        let adf_json = r#"{"version":1,"type":"doc","content":[
17980          {"type":"expand","attrs":{"title":"Props"},"parameters":{"macroMetadata":{"macroId":{"value":"m-001"},"schemaVersion":{"value":"1"}}},"content":[
17981            {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17982          ]}
17983        ]}"#;
17984        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17985        assert!(doc.content[0].parameters.is_some());
17986        let md = adf_to_markdown(&doc).unwrap();
17987        assert!(md.contains("params="), "JFM should contain params: {md}");
17988        let rt = markdown_to_adf(&md).unwrap();
17989        let expand = &rt.content[0];
17990        let params = expand
17991            .parameters
17992            .as_ref()
17993            .expect("parameters should survive round-trip");
17994        assert_eq!(params["macroMetadata"]["macroId"]["value"], "m-001");
17995        assert_eq!(params["macroMetadata"]["schemaVersion"]["value"], "1");
17996    }
17997
17998    #[test]
17999    fn expand_localid_and_parameters_roundtrip() {
18000        // Issue #444: both localId and parameters on expand should survive round-trip
18001        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"}]}]}]}"#;
18002        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18003        let md = adf_to_markdown(&doc).unwrap();
18004        let rt = markdown_to_adf(&md).unwrap();
18005        let expand = &rt.content[0];
18006        assert_eq!(expand.node_type, "expand");
18007        assert_eq!(expand.local_id.as_deref(), Some("abc-123"));
18008        assert_eq!(expand.attrs.as_ref().unwrap()["title"], "My Section");
18009        let params = expand
18010            .parameters
18011            .as_ref()
18012            .expect("parameters should survive");
18013        assert_eq!(params["macroMetadata"]["macroId"]["value"], "macro-001");
18014        assert_eq!(params["macroMetadata"]["title"], "Page Properties");
18015    }
18016
18017    #[test]
18018    fn nested_expand_top_level_localid_and_parameters_roundtrip() {
18019        let adf_json = r#"{"version":1,"type":"doc","content":[
18020          {"type":"nestedExpand","attrs":{"title":"Nested"},"localId":"ne-100","parameters":{"macroMetadata":{"macroId":{"value":"nm-001"}}},"content":[
18021            {"type":"paragraph","content":[{"type":"text","text":"inner"}]}
18022          ]}
18023        ]}"#;
18024        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18025        let md = adf_to_markdown(&doc).unwrap();
18026        assert!(
18027            md.contains(":::nested-expand{"),
18028            "should use nested-expand: {md}"
18029        );
18030        assert!(md.contains("localId=ne-100"), "should have localId: {md}");
18031        assert!(md.contains("params="), "should have params: {md}");
18032        let rt = markdown_to_adf(&md).unwrap();
18033        let ne = &rt.content[0];
18034        assert_eq!(ne.node_type, "nestedExpand");
18035        assert_eq!(ne.local_id.as_deref(), Some("ne-100"));
18036        assert_eq!(
18037            ne.parameters.as_ref().unwrap()["macroMetadata"]["macroId"]["value"],
18038            "nm-001"
18039        );
18040    }
18041
18042    #[test]
18043    fn expand_top_level_localid_stripped() {
18044        // strip_local_ids should strip top-level localId too
18045        let adf_json = r#"{"version":1,"type":"doc","content":[
18046          {"type":"expand","attrs":{"title":"X"},"localId":"exp-strip","content":[
18047            {"type":"paragraph","content":[{"type":"text","text":"body"}]}
18048          ]}
18049        ]}"#;
18050        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18051        let opts = RenderOptions {
18052            strip_local_ids: true,
18053        };
18054        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
18055        assert!(!md.contains("localId"), "localId should be stripped: {md}");
18056        assert!(
18057            md.contains(":::expand{title=\"X\"}"),
18058            "title should remain: {md}"
18059        );
18060    }
18061
18062    #[test]
18063    fn expand_parameters_without_localid() {
18064        // parameters without localId should work
18065        let adf_json = r#"{"version":1,"type":"doc","content":[
18066          {"type":"expand","attrs":{"title":"P"},"parameters":{"macroMetadata":{"macroId":{"value":"solo"}}},"content":[
18067            {"type":"paragraph","content":[{"type":"text","text":"data"}]}
18068          ]}
18069        ]}"#;
18070        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18071        let md = adf_to_markdown(&doc).unwrap();
18072        assert!(!md.contains("localId"), "no localId: {md}");
18073        assert!(md.contains("params="), "has params: {md}");
18074        let rt = markdown_to_adf(&md).unwrap();
18075        assert!(rt.content[0].local_id.is_none());
18076        assert_eq!(
18077            rt.content[0].parameters.as_ref().unwrap()["macroMetadata"]["macroId"]["value"],
18078            "solo"
18079        );
18080    }
18081
18082    #[test]
18083    fn expand_localid_without_parameters() {
18084        // top-level localId without parameters should work
18085        let adf_json = r#"{"version":1,"type":"doc","content":[
18086          {"type":"expand","attrs":{"title":"L"},"localId":"lid-only","content":[
18087            {"type":"paragraph","content":[{"type":"text","text":"txt"}]}
18088          ]}
18089        ]}"#;
18090        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18091        let md = adf_to_markdown(&doc).unwrap();
18092        assert!(md.contains("localId=lid-only"), "has localId: {md}");
18093        assert!(!md.contains("params="), "no params: {md}");
18094        let rt = markdown_to_adf(&md).unwrap();
18095        assert_eq!(rt.content[0].local_id.as_deref(), Some("lid-only"));
18096        assert!(rt.content[0].parameters.is_none());
18097    }
18098
18099    #[test]
18100    fn nested_panel_inside_panel() {
18101        let md = ":::panel{type=info}\n:::panel{type=warning}\nInner warning\n:::\n:::";
18102        let adf = markdown_to_adf(md).unwrap();
18103
18104        // Outer panel should exist
18105        assert_eq!(adf.content.len(), 1);
18106        assert_eq!(adf.content[0].node_type, "panel");
18107
18108        // Outer panel should contain an inner panel (not have it truncated)
18109        let panel_content = adf.content[0].content.as_ref().unwrap();
18110        assert!(
18111            panel_content.iter().any(|n| n.node_type == "panel"),
18112            "Outer panel should contain an inner panel, got: {:?}",
18113            panel_content
18114                .iter()
18115                .map(|n| &n.node_type)
18116                .collect::<Vec<_>>()
18117        );
18118    }
18119
18120    #[test]
18121    fn content_after_directive_table_is_preserved() {
18122        // Issue #361: content after a ::::table block was silently dropped
18123        let md = "\
18124## Before table
18125
18126::::table{layout=default}
18127:::tr
18128:::th{}
18129Cell
18130:::
18131:::
18132::::
18133
18134## After table
18135
18136Paragraph after.";
18137        let adf = markdown_to_adf(md).unwrap();
18138        let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
18139        assert_eq!(
18140            types,
18141            vec!["heading", "table", "heading", "paragraph"],
18142            "Content after table was dropped: got {types:?}"
18143        );
18144    }
18145
18146    #[test]
18147    fn paragraph_after_directive_table_is_preserved() {
18148        // Issue #361: minimal reproducer — paragraph after table
18149        let md = "\
18150::::table{layout=default}
18151:::tr
18152:::th{}
18153Header
18154:::
18155:::
18156::::
18157
18158Just a paragraph.";
18159        let adf = markdown_to_adf(md).unwrap();
18160        let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
18161        assert_eq!(
18162            types,
18163            vec!["table", "paragraph"],
18164            "Paragraph after table was dropped: got {types:?}"
18165        );
18166    }
18167
18168    #[test]
18169    fn extension_after_directive_table_is_preserved() {
18170        // Issue #361: extension after table
18171        let md = "\
18172::::table{layout=default}
18173:::tr
18174:::th{}
18175Header
18176:::
18177:::
18178::::
18179
18180::extension{type=com.atlassian.confluence.macro.core key=toc}";
18181        let adf = markdown_to_adf(md).unwrap();
18182        let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
18183        assert_eq!(
18184            types,
18185            vec!["table", "extension"],
18186            "Extension after table was dropped: got {types:?}"
18187        );
18188    }
18189
18190    #[test]
18191    fn multiple_blocks_after_directive_table() {
18192        // Issue #361: multiple blocks after table, including another table
18193        let md = "\
18194## Heading 1
18195
18196::::table{layout=default}
18197:::tr
18198:::td{}
18199A
18200:::
18201:::td{}
18202B
18203:::
18204:::
18205::::
18206
18207## Heading 2
18208
18209Some text.
18210
18211---
18212
18213::::table{layout=default}
18214:::tr
18215:::th{}
18216C
18217:::
18218:::
18219::::
18220
18221## Heading 3";
18222        let adf = markdown_to_adf(md).unwrap();
18223        let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
18224        assert_eq!(
18225            types,
18226            vec![
18227                "heading",
18228                "table",
18229                "heading",
18230                "paragraph",
18231                "rule",
18232                "table",
18233                "heading"
18234            ],
18235            "Content after tables was dropped: got {types:?}"
18236        );
18237    }
18238
18239    // ── Table caption tests (issue #382) ────────────────────────────
18240
18241    #[test]
18242    fn adf_table_caption_to_markdown() {
18243        let doc = AdfDocument {
18244            version: 1,
18245            doc_type: "doc".to_string(),
18246            content: vec![AdfNode::table(vec![
18247                AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
18248                    AdfNode::text("cell"),
18249                ])])]),
18250                AdfNode::caption(vec![AdfNode::text("Table caption")]),
18251            ])],
18252        };
18253        let md = adf_to_markdown(&doc).unwrap();
18254        assert!(
18255            md.contains("::::table"),
18256            "table with caption must use directive form"
18257        );
18258        assert!(
18259            md.contains(":::caption"),
18260            "caption directive missing, got: {md}"
18261        );
18262        assert!(
18263            md.contains("Table caption"),
18264            "caption text missing, got: {md}"
18265        );
18266    }
18267
18268    #[test]
18269    fn directive_table_caption_parses() {
18270        let md = "::::table\n:::tr\n:::td\ncell\n:::\n:::\n:::caption\nTable caption\n:::\n::::\n";
18271        let doc = markdown_to_adf(md).unwrap();
18272        let table = &doc.content[0];
18273        assert_eq!(table.node_type, "table");
18274        let children = table.content.as_ref().unwrap();
18275        assert_eq!(children.len(), 2, "expected row + caption");
18276        assert_eq!(children[0].node_type, "tableRow");
18277        assert_eq!(children[1].node_type, "caption");
18278        let caption_content = children[1].content.as_ref().unwrap();
18279        assert_eq!(caption_content[0].text.as_deref(), Some("Table caption"));
18280    }
18281
18282    #[test]
18283    fn table_caption_round_trip_from_adf_json() {
18284        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
18285          {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
18286          {"type":"caption","content":[{"type":"text","text":"Table caption"}]}
18287        ]}]}"#;
18288        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18289        let md = adf_to_markdown(&doc).unwrap();
18290        assert!(md.contains("Table caption"), "caption text lost in ADF→JFM");
18291        let round_tripped = markdown_to_adf(&md).unwrap();
18292        let children = round_tripped.content[0].content.as_ref().unwrap();
18293        let caption = children.iter().find(|n| n.node_type == "caption");
18294        assert!(caption.is_some(), "caption lost on round-trip");
18295        let caption_text = caption.unwrap().content.as_ref().unwrap();
18296        assert_eq!(caption_text[0].text.as_deref(), Some("Table caption"));
18297    }
18298
18299    #[test]
18300    fn table_caption_with_inline_marks_round_trips() {
18301        let doc = AdfDocument {
18302            version: 1,
18303            doc_type: "doc".to_string(),
18304            content: vec![AdfNode::table(vec![
18305                AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
18306                    AdfNode::text("data"),
18307                ])])]),
18308                AdfNode::caption(vec![
18309                    AdfNode::text("Caption with "),
18310                    AdfNode::text_with_marks("bold", vec![AdfMark::strong()]),
18311                ]),
18312            ])],
18313        };
18314        let md = adf_to_markdown(&doc).unwrap();
18315        assert!(md.contains("**bold**"), "bold mark missing in caption");
18316        let round_tripped = markdown_to_adf(&md).unwrap();
18317        let caption = round_tripped.content[0]
18318            .content
18319            .as_ref()
18320            .unwrap()
18321            .iter()
18322            .find(|n| n.node_type == "caption")
18323            .expect("caption node missing after round-trip");
18324        let inlines = caption.content.as_ref().unwrap();
18325        let bold_node = inlines.iter().find(|n| {
18326            n.marks
18327                .as_ref()
18328                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
18329        });
18330        assert!(bold_node.is_some(), "bold mark lost in caption round-trip");
18331    }
18332
18333    // ── table caption localId tests (issue #524) ──────────────────────
18334
18335    #[test]
18336    fn table_caption_localid_roundtrip() {
18337        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
18338          {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
18339          {"type":"caption","attrs":{"localId":"abcdef123456"},"content":[{"type":"text","text":"Table with localId"}]}
18340        ]}]}"#;
18341        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18342        let md = adf_to_markdown(&doc).unwrap();
18343        assert!(
18344            md.contains("localId=abcdef123456"),
18345            "table caption localId should appear in markdown: {md}"
18346        );
18347        let rt = markdown_to_adf(&md).unwrap();
18348        let caption = rt.content[0]
18349            .content
18350            .as_ref()
18351            .unwrap()
18352            .iter()
18353            .find(|n| n.node_type == "caption")
18354            .expect("caption should survive round-trip");
18355        assert_eq!(
18356            caption.attrs.as_ref().unwrap()["localId"],
18357            "abcdef123456",
18358            "table caption localId should round-trip"
18359        );
18360    }
18361
18362    #[test]
18363    fn table_caption_without_localid_unchanged() {
18364        let md = "::::table\n:::tr\n:::td\ncell\n:::\n:::\n:::caption\nPlain caption\n:::\n::::\n";
18365        let doc = markdown_to_adf(md).unwrap();
18366        let caption = doc.content[0]
18367            .content
18368            .as_ref()
18369            .unwrap()
18370            .iter()
18371            .find(|n| n.node_type == "caption")
18372            .unwrap();
18373        assert!(
18374            caption.attrs.is_none(),
18375            "table caption without localId should not gain attrs"
18376        );
18377        let md2 = adf_to_markdown(&doc).unwrap();
18378        assert!(!md2.contains("localId"), "no localId should appear: {md2}");
18379    }
18380
18381    #[test]
18382    fn table_caption_localid_stripped_when_option_set() {
18383        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
18384          {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
18385          {"type":"caption","attrs":{"localId":"abcdef123456"},"content":[{"type":"text","text":"Stripped"}]}
18386        ]}]}"#;
18387        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18388        let opts = RenderOptions {
18389            strip_local_ids: true,
18390            ..Default::default()
18391        };
18392        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
18393        assert!(
18394            !md.contains("localId"),
18395            "table caption localId should be stripped: {md}"
18396        );
18397    }
18398
18399    #[test]
18400    #[test]
18401    fn tablecell_empty_attrs_preserved_on_roundtrip() {
18402        // Issue #385: tableCell with empty attrs:{} dropped on round-trip
18403        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"}]}]}]}]}]}"#;
18404        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18405        let md = adf_to_markdown(&doc).unwrap();
18406        let round_tripped = markdown_to_adf(&md).unwrap();
18407        let rows = round_tripped.content[0].content.as_ref().unwrap();
18408        let cell = &rows[0].content.as_ref().unwrap()[0];
18409        assert!(
18410            cell.attrs.is_some(),
18411            "tableCell attrs should be preserved, got None"
18412        );
18413        assert_eq!(
18414            cell.attrs.as_ref().unwrap(),
18415            &serde_json::json!({}),
18416            "tableCell attrs should be an empty object"
18417        );
18418    }
18419
18420    #[test]
18421    fn tablecell_empty_attrs_serialized_in_json() {
18422        // Issue #385: ensure the serialized JSON includes "attrs":{}
18423        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"}]}]}]}]}]}"#;
18424        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18425        let md = adf_to_markdown(&doc).unwrap();
18426        let round_tripped = markdown_to_adf(&md).unwrap();
18427        let json = serde_json::to_string(&round_tripped).unwrap();
18428        assert!(
18429            json.contains(r#""attrs":{}"#),
18430            "serialized JSON should contain \"attrs\":{{}}, got: {json}"
18431        );
18432    }
18433
18434    #[test]
18435    fn tablecell_empty_attrs_renders_braces_in_markdown() {
18436        // Issue #385: tableCell with empty attrs should render {} prefix in pipe tables
18437        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"}]}]}]}]}]}"#;
18438        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18439        let md = adf_to_markdown(&doc).unwrap();
18440        // Cell with attrs:{} should have {} prefix, cell without attrs should not
18441        assert!(
18442            md.contains("{} hello"),
18443            "cell with empty attrs should render '{{}} hello', got: {md}"
18444        );
18445        assert!(
18446            !md.contains("{} world"),
18447            "cell without attrs should not render '{{}}', got: {md}"
18448        );
18449    }
18450
18451    #[test]
18452    fn tablecell_no_attrs_unchanged_on_roundtrip() {
18453        // Ensure tableCell without attrs stays without attrs
18454        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"}]}]}]}]}]}"#;
18455        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18456        let md = adf_to_markdown(&doc).unwrap();
18457        let round_tripped = markdown_to_adf(&md).unwrap();
18458        let rows = round_tripped.content[0].content.as_ref().unwrap();
18459        let cell = &rows[0].content.as_ref().unwrap()[0];
18460        assert!(
18461            cell.attrs.is_none(),
18462            "tableCell without attrs should stay None, got: {:?}",
18463            cell.attrs
18464        );
18465    }
18466
18467    #[test]
18468    fn tablecell_nonempty_attrs_preserved_on_roundtrip() {
18469        // Ensure tableCell with non-empty attrs still works
18470        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"}]}]}]}]}]}"##;
18471        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18472        let md = adf_to_markdown(&doc).unwrap();
18473        let round_tripped = markdown_to_adf(&md).unwrap();
18474        let rows = round_tripped.content[0].content.as_ref().unwrap();
18475        let cell = &rows[1].content.as_ref().unwrap()[0];
18476        let attrs = cell.attrs.as_ref().unwrap();
18477        assert_eq!(attrs["background"], "#DEEBFF");
18478        assert_eq!(attrs["colspan"], 2);
18479    }
18480
18481    #[test]
18482    fn pipe_table_not_used_when_caption_present() {
18483        let doc = AdfDocument {
18484            version: 1,
18485            doc_type: "doc".to_string(),
18486            content: vec![AdfNode::table(vec![
18487                AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
18488                    AdfNode::text("H"),
18489                ])])]),
18490                AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
18491                    AdfNode::text("D"),
18492                ])])]),
18493                AdfNode::caption(vec![AdfNode::text("cap")]),
18494            ])],
18495        };
18496        let md = adf_to_markdown(&doc).unwrap();
18497        assert!(
18498            md.contains("::::table"),
18499            "pipe syntax should not be used when caption is present"
18500        );
18501    }
18502
18503    // ── Issue #402: ordered-list-like text in list item hardBreak ──
18504
18505    #[test]
18506    fn hardbreak_with_ordered_marker_in_bullet_item_roundtrips() {
18507        // Issue #402: text starting with "2. " after a hardBreak inside a
18508        // bullet list item must not be re-parsed as a new ordered list.
18509        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18510          {"type":"listItem","content":[{"type":"paragraph","content":[
18511            {"type":"text","text":"1. First item"},
18512            {"type":"hardBreak"},
18513            {"type":"text","text":"2. Honouring existing commitments"}
18514          ]}]}
18515        ]}]}"#;
18516        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18517        let md = adf_to_markdown(&doc).unwrap();
18518
18519        // The continuation line must be indented so it stays within the list item.
18520        assert!(
18521            md.contains("  2. Honouring"),
18522            "Continuation line should be indented, got:\n{md}"
18523        );
18524
18525        // Round-trip back to ADF
18526        let rt = markdown_to_adf(&md).unwrap();
18527        let list = &rt.content[0];
18528        assert_eq!(list.node_type, "bulletList");
18529        let items = list.content.as_ref().unwrap();
18530        assert_eq!(
18531            items.len(),
18532            1,
18533            "Should be one list item, got {}",
18534            items.len()
18535        );
18536
18537        let para = &items[0].content.as_ref().unwrap()[0];
18538        let inlines = para.content.as_ref().unwrap();
18539        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18540        assert_eq!(
18541            types,
18542            vec!["text", "hardBreak", "text"],
18543            "Expected text+hardBreak+text, got {types:?}"
18544        );
18545        assert_eq!(
18546            inlines[2].text.as_deref().unwrap(),
18547            "2. Honouring existing commitments"
18548        );
18549    }
18550
18551    #[test]
18552    fn hardbreak_with_ordered_marker_in_ordered_item_roundtrips() {
18553        // Same as above but inside an ordered list.
18554        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
18555          {"type":"listItem","content":[{"type":"paragraph","content":[
18556            {"type":"text","text":"Introduction  "},
18557            {"type":"hardBreak"},
18558            {"type":"text","text":"3. Third point"}
18559          ]}]}
18560        ]}]}"#;
18561        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18562        let md = adf_to_markdown(&doc).unwrap();
18563        let rt = markdown_to_adf(&md).unwrap();
18564
18565        let list = &rt.content[0];
18566        assert_eq!(list.node_type, "orderedList");
18567        let items = list.content.as_ref().unwrap();
18568        assert_eq!(items.len(), 1);
18569
18570        let para = &items[0].content.as_ref().unwrap()[0];
18571        let inlines = para.content.as_ref().unwrap();
18572        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18573        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18574        assert_eq!(inlines[2].text.as_deref().unwrap(), "3. Third point");
18575    }
18576
18577    #[test]
18578    fn hardbreak_with_bullet_marker_in_bullet_item_roundtrips() {
18579        // Text starting with "- " after a hardBreak must not become a nested bullet list.
18580        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18581          {"type":"listItem","content":[{"type":"paragraph","content":[
18582            {"type":"text","text":"Header  "},
18583            {"type":"hardBreak"},
18584            {"type":"text","text":"- not a sub-item"}
18585          ]}]}
18586        ]}]}"#;
18587        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18588        let md = adf_to_markdown(&doc).unwrap();
18589        let rt = markdown_to_adf(&md).unwrap();
18590
18591        let list = &rt.content[0];
18592        assert_eq!(list.node_type, "bulletList");
18593        let items = list.content.as_ref().unwrap();
18594        assert_eq!(
18595            items.len(),
18596            1,
18597            "Should be one list item, not {}",
18598            items.len()
18599        );
18600
18601        let para = &items[0].content.as_ref().unwrap()[0];
18602        let inlines = para.content.as_ref().unwrap();
18603        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18604        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18605        assert_eq!(inlines[2].text.as_deref().unwrap(), "- not a sub-item");
18606    }
18607
18608    #[test]
18609    fn hardbreak_continuation_followed_by_sub_list() {
18610        // A hardBreak continuation line followed by a real sub-list.
18611        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18612          {"type":"listItem","content":[
18613            {"type":"paragraph","content":[
18614              {"type":"text","text":"Main item  "},
18615              {"type":"hardBreak"},
18616              {"type":"text","text":"continued here"}
18617            ]},
18618            {"type":"bulletList","content":[
18619              {"type":"listItem","content":[{"type":"paragraph","content":[
18620                {"type":"text","text":"sub-item"}
18621              ]}]}
18622            ]}
18623          ]}
18624        ]}]}"#;
18625        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18626        let md = adf_to_markdown(&doc).unwrap();
18627        let rt = markdown_to_adf(&md).unwrap();
18628
18629        let list = &rt.content[0];
18630        let items = list.content.as_ref().unwrap();
18631        assert_eq!(items.len(), 1);
18632
18633        let item_content = items[0].content.as_ref().unwrap();
18634        assert_eq!(item_content.len(), 2, "Expected paragraph + nested list");
18635        assert_eq!(item_content[0].node_type, "paragraph");
18636        assert_eq!(item_content[1].node_type, "bulletList");
18637
18638        // Check the paragraph has hardBreak
18639        let inlines = item_content[0].content.as_ref().unwrap();
18640        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18641        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18642    }
18643
18644    #[test]
18645    fn multiple_hardbreaks_with_numbered_text_roundtrip() {
18646        // Multiple hardBreaks where each continuation resembles an ordered list.
18647        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18648          {"type":"listItem","content":[{"type":"paragraph","content":[
18649            {"type":"text","text":"Preamble  "},
18650            {"type":"hardBreak"},
18651            {"type":"text","text":"1. Alpha  "},
18652            {"type":"hardBreak"},
18653            {"type":"text","text":"2. Bravo"}
18654          ]}]}
18655        ]}]}"#;
18656        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18657        let md = adf_to_markdown(&doc).unwrap();
18658        let rt = markdown_to_adf(&md).unwrap();
18659
18660        let items = rt.content[0].content.as_ref().unwrap();
18661        assert_eq!(items.len(), 1);
18662
18663        let inlines = items[0].content.as_ref().unwrap()[0]
18664            .content
18665            .as_ref()
18666            .unwrap();
18667        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18668        assert_eq!(
18669            types,
18670            vec!["text", "hardBreak", "text", "hardBreak", "text"]
18671        );
18672    }
18673
18674    #[test]
18675    fn trailing_hardbreak_in_bullet_item_roundtrips() {
18676        // A hardBreak as the last inline node with no text after it.
18677        // Exercises the `break` path in the continuation loop and the
18678        // empty-line rendering branch.
18679        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18680          {"type":"listItem","content":[{"type":"paragraph","content":[
18681            {"type":"text","text":"ends with break"},
18682            {"type":"hardBreak"}
18683          ]}]}
18684        ]}]}"#;
18685        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18686        let md = adf_to_markdown(&doc).unwrap();
18687        let rt = markdown_to_adf(&md).unwrap();
18688
18689        let list = &rt.content[0];
18690        assert_eq!(list.node_type, "bulletList");
18691        let inlines = list.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0]
18692            .content
18693            .as_ref()
18694            .unwrap();
18695        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18696        assert_eq!(types, vec!["text", "hardBreak"]);
18697    }
18698
18699    #[test]
18700    fn trailing_hardbreak_in_ordered_item_roundtrips() {
18701        // Same as above but in an ordered list, covering the ordered-list
18702        // continuation `break` path.
18703        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
18704          {"type":"listItem","content":[{"type":"paragraph","content":[
18705            {"type":"text","text":"ends with break"},
18706            {"type":"hardBreak"}
18707          ]}]}
18708        ]}]}"#;
18709        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18710        let md = adf_to_markdown(&doc).unwrap();
18711        let rt = markdown_to_adf(&md).unwrap();
18712
18713        let list = &rt.content[0];
18714        assert_eq!(list.node_type, "orderedList");
18715        let inlines = list.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0]
18716            .content
18717            .as_ref()
18718            .unwrap();
18719        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18720        assert_eq!(types, vec!["text", "hardBreak"]);
18721    }
18722
18723    #[test]
18724    fn trailing_space_hardbreak_continuation_in_bullet_item() {
18725        // Exercises the `ends_with("  ")` path in `has_trailing_hard_break`
18726        // by parsing hand-written markdown that uses trailing-space style
18727        // hardBreaks instead of backslash style.
18728        let md = "- first line  \n  2. continued\n";
18729        let doc = markdown_to_adf(md).unwrap();
18730
18731        let list = &doc.content[0];
18732        assert_eq!(list.node_type, "bulletList");
18733        let items = list.content.as_ref().unwrap();
18734        assert_eq!(
18735            items.len(),
18736            1,
18737            "Should be one list item, got {}",
18738            items.len()
18739        );
18740
18741        let para = &items[0].content.as_ref().unwrap()[0];
18742        let inlines = para.content.as_ref().unwrap();
18743        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18744        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18745        assert_eq!(inlines[2].text.as_deref().unwrap(), "2. continued");
18746    }
18747
18748    #[test]
18749    fn trailing_space_hardbreak_continuation_in_ordered_item() {
18750        // Same as above but for ordered list, exercising the trailing-space
18751        // path in the ordered-list continuation loop.
18752        let md = "1. first line  \n  - continued\n";
18753        let doc = markdown_to_adf(md).unwrap();
18754
18755        let list = &doc.content[0];
18756        assert_eq!(list.node_type, "orderedList");
18757        let items = list.content.as_ref().unwrap();
18758        assert_eq!(
18759            items.len(),
18760            1,
18761            "Should be one list item, got {}",
18762            items.len()
18763        );
18764
18765        let para = &items[0].content.as_ref().unwrap()[0];
18766        let inlines = para.content.as_ref().unwrap();
18767        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18768        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18769        assert_eq!(inlines[2].text.as_deref().unwrap(), "- continued");
18770    }
18771
18772    #[test]
18773    fn multi_paragraph_list_item_with_ordered_marker_roundtrips() {
18774        // Issue #402 comment: a listItem with a second paragraph starting
18775        // with "2. " must not become a separate orderedList.
18776        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18777          {"type":"listItem","content":[
18778            {"type":"paragraph","content":[{"type":"text","text":"some preamble"}]},
18779            {"type":"paragraph","content":[{"type":"text","text":"2. Honouring existing commitments"}]}
18780          ]}
18781        ]}]}"#;
18782        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18783        let md = adf_to_markdown(&doc).unwrap();
18784        let rt = markdown_to_adf(&md).unwrap();
18785
18786        assert_eq!(rt.content.len(), 1, "Should be one top-level block");
18787        let list = &rt.content[0];
18788        assert_eq!(list.node_type, "bulletList");
18789        let items = list.content.as_ref().unwrap();
18790        assert_eq!(items.len(), 1);
18791        let item_content = items[0].content.as_ref().unwrap();
18792        assert_eq!(
18793            item_content.len(),
18794            2,
18795            "Expected 2 paragraphs inside the list item, got {}",
18796            item_content.len()
18797        );
18798        assert_eq!(item_content[0].node_type, "paragraph");
18799        assert_eq!(item_content[1].node_type, "paragraph");
18800        let text = item_content[1].content.as_ref().unwrap()[0]
18801            .text
18802            .as_deref()
18803            .unwrap();
18804        assert_eq!(text, "2. Honouring existing commitments");
18805    }
18806
18807    #[test]
18808    fn multi_paragraph_list_item_with_bullet_marker_roundtrips() {
18809        // Paragraph starting with "- " inside a list item.
18810        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18811          {"type":"listItem","content":[
18812            {"type":"paragraph","content":[{"type":"text","text":"preamble"}]},
18813            {"type":"paragraph","content":[{"type":"text","text":"- not a sub-item"}]}
18814          ]}
18815        ]}]}"#;
18816        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18817        let md = adf_to_markdown(&doc).unwrap();
18818        let rt = markdown_to_adf(&md).unwrap();
18819
18820        let items = rt.content[0].content.as_ref().unwrap();
18821        assert_eq!(items.len(), 1);
18822        let item_content = items[0].content.as_ref().unwrap();
18823        assert_eq!(item_content.len(), 2);
18824        assert_eq!(item_content[1].node_type, "paragraph");
18825        let text = item_content[1].content.as_ref().unwrap()[0]
18826            .text
18827            .as_deref()
18828            .unwrap();
18829        assert_eq!(text, "- not a sub-item");
18830    }
18831
18832    #[test]
18833    fn backslash_escape_in_inline_text() {
18834        // Verify that `\. ` is unescaped to `. ` in inline parsing.
18835        let nodes = parse_inline(r"2\. text");
18836        assert_eq!(nodes.len(), 1, "Should be one text node");
18837        assert_eq!(nodes[0].text.as_deref().unwrap(), "2. text");
18838    }
18839
18840    #[test]
18841    fn escape_list_marker_ordered() {
18842        assert_eq!(escape_list_marker("2. text"), r"2\. text");
18843        assert_eq!(escape_list_marker("10. tenth"), r"10\. tenth");
18844    }
18845
18846    #[test]
18847    fn escape_list_marker_bullet() {
18848        assert_eq!(escape_list_marker("- text"), r"\- text");
18849        assert_eq!(escape_list_marker("* text"), r"\* text");
18850        assert_eq!(escape_list_marker("+ text"), r"\+ text");
18851    }
18852
18853    #[test]
18854    fn escape_list_marker_plain() {
18855        assert_eq!(escape_list_marker("plain text"), "plain text");
18856        assert_eq!(escape_list_marker("no. marker"), "no. marker");
18857    }
18858
18859    #[test]
18860    fn escape_emoji_shortcodes_basic() {
18861        assert_eq!(escape_emoji_shortcodes(":fire:"), r"\:fire:");
18862        assert_eq!(
18863            escape_emoji_shortcodes("hello :wave: world"),
18864            r"hello \:wave: world"
18865        );
18866    }
18867
18868    #[test]
18869    fn escape_emoji_shortcodes_double_colon() {
18870        // Only the colon that starts `:Active:` needs escaping
18871        assert_eq!(
18872            escape_emoji_shortcodes("Status::Active::Running"),
18873            r"Status:\:Active::Running"
18874        );
18875    }
18876
18877    #[test]
18878    fn escape_emoji_shortcodes_no_match() {
18879        // Lone colons, numeric-only between colons like 10:30
18880        assert_eq!(escape_emoji_shortcodes("Time is 10:30"), "Time is 10:30");
18881        assert_eq!(escape_emoji_shortcodes("no colons here"), "no colons here");
18882        assert_eq!(escape_emoji_shortcodes("trailing:"), "trailing:");
18883        assert_eq!(escape_emoji_shortcodes(":"), ":");
18884    }
18885
18886    #[test]
18887    fn escape_emoji_shortcodes_mixed() {
18888        assert_eq!(
18889            escape_emoji_shortcodes("Alert :fire: on pod:pod42"),
18890            r"Alert \:fire: on pod:pod42"
18891        );
18892    }
18893
18894    #[test]
18895    fn escape_emoji_shortcodes_unicode() {
18896        // Issue #552: Unicode alphanumeric chars must be escaped to match
18897        // `try_parse_emoji_shortcode`, which uses `is_alphanumeric` (not the
18898        // ASCII-only variant).  Without this, `:Café:` rendered un-escaped
18899        // would be re-parsed as an emoji on round-trip.
18900        assert_eq!(escape_emoji_shortcodes(":Café:"), r"\:Café:");
18901        assert_eq!(escape_emoji_shortcodes(":über:"), r"\:über:");
18902        assert_eq!(escape_emoji_shortcodes(":配置:"), r"\:配置:");
18903        assert_eq!(
18904            escape_emoji_shortcodes("ZBC::配置::Production"),
18905            r"ZBC:\:配置::Production"
18906        );
18907    }
18908
18909    #[test]
18910    fn escape_emoji_shortcodes_mixed_script_name() {
18911        // Issue #552: A name that mixes ASCII and Unicode alphanumerics is
18912        // still a single valid shortcode under `is_alphanumeric`.
18913        assert_eq!(escape_emoji_shortcodes(":abc配置:"), r"\:abc配置:");
18914        assert_eq!(escape_emoji_shortcodes(":配置abc:"), r"\:配置abc:");
18915    }
18916
18917    #[test]
18918    fn escape_emoji_shortcodes_unicode_followed_by_non_colon() {
18919        // `:Café world:` — `Café` is alphanumeric but the terminator is a
18920        // space, not `:`, so the `after + name_end < text.len()` path is
18921        // exercised but the final `== b':'` check bails out and nothing
18922        // gets escaped.  Guards the negative branch of the predicate.
18923        assert_eq!(escape_emoji_shortcodes(":Café world:"), ":Café world:");
18924    }
18925
18926    #[test]
18927    fn escape_emoji_shortcodes_name_runs_to_end() {
18928        // Colon followed by alphanumerics to end of string: `.find(...)` returns
18929        // `None`, so `name_end` falls back to the full remaining length via
18930        // `map_or`.  The `after + name_end < text.len()` check then fails and
18931        // nothing is escaped.  Exercises the `map_or` default branch for both
18932        // ASCII and Unicode names.
18933        assert_eq!(escape_emoji_shortcodes(":abc"), ":abc");
18934        assert_eq!(escape_emoji_shortcodes(":配置"), ":配置");
18935    }
18936
18937    #[test]
18938    fn unicode_shortcode_pattern_text_round_trips_as_text() {
18939        // Issue #552: A text node containing `:Café:` must round-trip as text,
18940        // not be split into text + emoji nodes.
18941        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18942          {"type":"text","text":"Visit :Café: today"}
18943        ]}]}"#;
18944        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18945
18946        let md = adf_to_markdown(&doc).unwrap();
18947        let round_tripped = markdown_to_adf(&md).unwrap();
18948        let content = round_tripped.content[0].content.as_ref().unwrap();
18949
18950        assert_eq!(
18951            content.len(),
18952            1,
18953            "should be a single text node, got: {content:?}"
18954        );
18955        assert_eq!(content[0].node_type, "text");
18956        assert_eq!(content[0].text.as_deref().unwrap(), "Visit :Café: today");
18957    }
18958
18959    #[test]
18960    fn unicode_double_colon_pattern_text_round_trips() {
18961        // Issue #552: `ZBC::配置::Production` should round-trip without splitting.
18962        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18963          {"type":"text","text":"Use ZBC::配置::Production for prod"}
18964        ]}]}"#;
18965        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18966
18967        let md = adf_to_markdown(&doc).unwrap();
18968        let round_tripped = markdown_to_adf(&md).unwrap();
18969        let content = round_tripped.content[0].content.as_ref().unwrap();
18970
18971        assert_eq!(
18972            content.len(),
18973            1,
18974            "should be a single text node, got: {content:?}"
18975        );
18976        assert_eq!(
18977            content[0].text.as_deref().unwrap(),
18978            "Use ZBC::配置::Production for prod"
18979        );
18980    }
18981
18982    #[test]
18983    fn merge_adjacent_text_nodes() {
18984        let mut nodes = vec![AdfNode::text("a"), AdfNode::text("b"), AdfNode::text("c")];
18985        merge_adjacent_text(&mut nodes);
18986        assert_eq!(nodes.len(), 1);
18987        assert_eq!(nodes[0].text.as_deref().unwrap(), "abc");
18988    }
18989
18990    // ── Issue #455: text after hardBreak in paragraph re-parsed as list ──
18991
18992    #[test]
18993    fn issue_455_paragraph_hardbreak_ordered_marker_roundtrips() {
18994        // Issue #455: "1. text" after a hardBreak in a paragraph must not
18995        // become an ordered list.
18996        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
18997          {"type":"text","text":"Introduction: "},
18998          {"type":"hardBreak"},
18999          {"type":"text","text":"1. This text follows a hardBreak"}
19000        ]}]}"#;
19001        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19002        let md = adf_to_markdown(&doc).unwrap();
19003        let rt = markdown_to_adf(&md).unwrap();
19004
19005        assert_eq!(rt.content.len(), 1, "Should remain one block");
19006        assert_eq!(rt.content[0].node_type, "paragraph");
19007        let inlines = rt.content[0].content.as_ref().unwrap();
19008        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19009        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19010        assert_eq!(
19011            inlines[2].text.as_deref(),
19012            Some("1. This text follows a hardBreak")
19013        );
19014    }
19015
19016    #[test]
19017    fn issue_455_paragraph_hardbreak_bullet_marker_roundtrips() {
19018        // Issue #455 variant: "- text" after a hardBreak in a paragraph.
19019        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19020          {"type":"text","text":"Intro"},
19021          {"type":"hardBreak"},
19022          {"type":"text","text":"- not a list item"}
19023        ]}]}"#;
19024        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19025        let md = adf_to_markdown(&doc).unwrap();
19026        let rt = markdown_to_adf(&md).unwrap();
19027
19028        assert_eq!(rt.content.len(), 1);
19029        assert_eq!(rt.content[0].node_type, "paragraph");
19030        let inlines = rt.content[0].content.as_ref().unwrap();
19031        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19032        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19033        assert_eq!(inlines[2].text.as_deref(), Some("- not a list item"));
19034    }
19035
19036    #[test]
19037    fn issue_455_paragraph_hardbreak_heading_marker_roundtrips() {
19038        // Issue #455 variant: "# text" after a hardBreak in a paragraph.
19039        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19040          {"type":"text","text":"Intro"},
19041          {"type":"hardBreak"},
19042          {"type":"text","text":"# not a heading"}
19043        ]}]}"##;
19044        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19045        let md = adf_to_markdown(&doc).unwrap();
19046        let rt = markdown_to_adf(&md).unwrap();
19047
19048        assert_eq!(rt.content.len(), 1);
19049        assert_eq!(rt.content[0].node_type, "paragraph");
19050        let inlines = rt.content[0].content.as_ref().unwrap();
19051        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19052        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19053        assert_eq!(inlines[2].text.as_deref(), Some("# not a heading"));
19054    }
19055
19056    #[test]
19057    fn issue_455_paragraph_hardbreak_blockquote_marker_roundtrips() {
19058        // Issue #455 variant: "> text" after a hardBreak in a paragraph.
19059        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19060          {"type":"text","text":"Intro"},
19061          {"type":"hardBreak"},
19062          {"type":"text","text":"> not a blockquote"}
19063        ]}]}"#;
19064        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19065        let md = adf_to_markdown(&doc).unwrap();
19066        let rt = markdown_to_adf(&md).unwrap();
19067
19068        assert_eq!(rt.content.len(), 1);
19069        assert_eq!(rt.content[0].node_type, "paragraph");
19070        let inlines = rt.content[0].content.as_ref().unwrap();
19071        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19072        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19073        assert_eq!(inlines[2].text.as_deref(), Some("> not a blockquote"));
19074    }
19075
19076    #[test]
19077    fn issue_455_paragraph_multiple_hardbreaks_with_ordered_markers() {
19078        // Multiple hardBreaks in a paragraph, each followed by "N. text".
19079        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19080          {"type":"text","text":"Preamble"},
19081          {"type":"hardBreak"},
19082          {"type":"text","text":"1. First"},
19083          {"type":"hardBreak"},
19084          {"type":"text","text":"2. Second"},
19085          {"type":"hardBreak"},
19086          {"type":"text","text":"3. Third"}
19087        ]}]}"#;
19088        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19089        let md = adf_to_markdown(&doc).unwrap();
19090        let rt = markdown_to_adf(&md).unwrap();
19091
19092        assert_eq!(rt.content.len(), 1);
19093        assert_eq!(rt.content[0].node_type, "paragraph");
19094        let inlines = rt.content[0].content.as_ref().unwrap();
19095        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19096        assert_eq!(
19097            types,
19098            vec![
19099                "text",
19100                "hardBreak",
19101                "text",
19102                "hardBreak",
19103                "text",
19104                "hardBreak",
19105                "text"
19106            ]
19107        );
19108        assert_eq!(inlines[2].text.as_deref(), Some("1. First"));
19109        assert_eq!(inlines[4].text.as_deref(), Some("2. Second"));
19110        assert_eq!(inlines[6].text.as_deref(), Some("3. Third"));
19111    }
19112
19113    #[test]
19114    fn issue_455_paragraph_hardbreak_jfm_indentation() {
19115        // Verify that ADF→JFM output indents continuation lines.
19116        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19117          {"type":"text","text":"Intro"},
19118          {"type":"hardBreak"},
19119          {"type":"text","text":"1. continued"}
19120        ]}]}"#;
19121        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19122        let md = adf_to_markdown(&doc).unwrap();
19123        assert!(
19124            md.contains("Intro\\\n  1. continued"),
19125            "Continuation should be 2-space-indented, got: {md:?}"
19126        );
19127    }
19128
19129    #[test]
19130    fn issue_455_paragraph_hardbreak_from_jfm() {
19131        // Verify that JFM with 2-space-indented continuation is parsed
19132        // back as a single paragraph with hardBreak.
19133        let md = "Intro\\\n  1. This is continuation text\n";
19134        let doc = markdown_to_adf(md).unwrap();
19135
19136        assert_eq!(doc.content.len(), 1);
19137        assert_eq!(doc.content[0].node_type, "paragraph");
19138        let inlines = doc.content[0].content.as_ref().unwrap();
19139        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19140        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19141        assert_eq!(
19142            inlines[2].text.as_deref(),
19143            Some("1. This is continuation text")
19144        );
19145    }
19146
19147    #[test]
19148    fn issue_455_paragraph_starts_with_ordered_marker_and_hardbreak() {
19149        // Coverage: first line IS a list marker AND paragraph has hardBreaks.
19150        // Exercises the escape_list_marker path on the first line of a
19151        // multi-line paragraph buf in the rendering code.
19152        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19153          {"type":"text","text":"1. Starting with a number"},
19154          {"type":"hardBreak"},
19155          {"type":"text","text":"continuation after break"}
19156        ]}]}"#;
19157        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19158        let md = adf_to_markdown(&doc).unwrap();
19159        // First line should be escaped so it's not parsed as ordered list
19160        assert!(
19161            md.contains(r"1\. Starting with a number"),
19162            "First line should have escaped list marker, got: {md:?}"
19163        );
19164        let rt = markdown_to_adf(&md).unwrap();
19165
19166        assert_eq!(rt.content.len(), 1);
19167        assert_eq!(rt.content[0].node_type, "paragraph");
19168        let inlines = rt.content[0].content.as_ref().unwrap();
19169        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19170        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19171        assert_eq!(
19172            inlines[0].text.as_deref(),
19173            Some("1. Starting with a number")
19174        );
19175        assert_eq!(inlines[2].text.as_deref(), Some("continuation after break"));
19176    }
19177
19178    #[test]
19179    fn ordered_marker_paragraph_in_table_cell_roundtrips() {
19180        // Issue #402: paragraph with "2. " text inside a tableCell must
19181        // not be re-parsed as an ordered list.
19182        let adf_json = r#"{"version":1,"type":"doc","content":[{
19183          "type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},
19184          "content":[{"type":"tableRow","content":[{
19185            "type":"tableCell","attrs":{"colspan":1,"rowspan":1},
19186            "content":[{"type":"paragraph","content":[
19187              {"type":"text","text":"2. Honouring existing commitments"}
19188            ]}]
19189          }]}]
19190        }]}"#;
19191        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19192        let md = adf_to_markdown(&doc).unwrap();
19193        let rt = markdown_to_adf(&md).unwrap();
19194
19195        let table = &rt.content[0];
19196        let cell = &table.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0];
19197        let para = &cell.content.as_ref().unwrap()[0];
19198        assert_eq!(para.node_type, "paragraph");
19199        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
19200        assert_eq!(text, "2. Honouring existing commitments");
19201    }
19202
19203    #[test]
19204    fn bullet_marker_paragraph_standalone_roundtrips() {
19205        // A top-level paragraph starting with "- " must round-trip as
19206        // a paragraph, not a bullet list.
19207        let adf_json = r#"{"version":1,"type":"doc","content":[
19208          {"type":"paragraph","content":[
19209            {"type":"text","text":"- not a list item"}
19210          ]}
19211        ]}"#;
19212        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19213        let md = adf_to_markdown(&doc).unwrap();
19214        assert!(
19215            md.contains(r"\- not a list item"),
19216            "Should escape the leading dash, got:\n{md}"
19217        );
19218        let rt = markdown_to_adf(&md).unwrap();
19219        assert_eq!(rt.content[0].node_type, "paragraph");
19220        let text = rt.content[0].content.as_ref().unwrap()[0]
19221            .text
19222            .as_deref()
19223            .unwrap();
19224        assert_eq!(text, "- not a list item");
19225    }
19226
19227    #[test]
19228    fn merge_adjacent_text_skips_non_text_nodes() {
19229        // Exercises the `else { i += 1 }` branch when adjacent nodes
19230        // are not both plain text.
19231        let mut nodes = vec![
19232            AdfNode::text("a"),
19233            AdfNode::hard_break(),
19234            AdfNode::text("b"),
19235        ];
19236        merge_adjacent_text(&mut nodes);
19237        assert_eq!(nodes.len(), 3);
19238    }
19239
19240    #[test]
19241    fn star_bullet_paragraph_roundtrips() {
19242        // Paragraph starting with "* " must round-trip without becoming
19243        // a bullet list.
19244        let adf_json = r#"{"version":1,"type":"doc","content":[
19245          {"type":"paragraph","content":[
19246            {"type":"text","text":"* starred"}
19247          ]}
19248        ]}"#;
19249        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19250        let md = adf_to_markdown(&doc).unwrap();
19251        let rt = markdown_to_adf(&md).unwrap();
19252        assert_eq!(rt.content[0].node_type, "paragraph");
19253        assert_eq!(
19254            rt.content[0].content.as_ref().unwrap()[0]
19255                .text
19256                .as_deref()
19257                .unwrap(),
19258            "* starred"
19259        );
19260    }
19261
19262    // ---- Issue #388 tests ----
19263
19264    #[test]
19265    fn issue_388_ordered_list_with_strong_hardbreak_roundtrips() {
19266        // Issue #388: orderedList with 2 listItems, each containing
19267        // strong-marked text + hardBreak + plain text.
19268        let adf_json = r#"{"version":1,"type":"doc","content":[
19269          {"type":"orderedList","attrs":{"order":1},"content":[
19270            {"type":"listItem","content":[
19271              {"type":"paragraph","content":[
19272                {"type":"text","text":"Bold heading","marks":[{"type":"strong"}]},
19273                {"type":"hardBreak"},
19274                {"type":"text","text":"Content after break"}
19275              ]}
19276            ]},
19277            {"type":"listItem","content":[
19278              {"type":"paragraph","content":[
19279                {"type":"text","text":"Second item","marks":[{"type":"strong"}]},
19280                {"type":"hardBreak"},
19281                {"type":"text","text":"More content"}
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        // Must remain a single orderedList
19291        assert_eq!(
19292            rt.content.len(),
19293            1,
19294            "Should be 1 block (orderedList), got {}",
19295            rt.content.len()
19296        );
19297        assert_eq!(rt.content[0].node_type, "orderedList");
19298        let items = rt.content[0].content.as_ref().unwrap();
19299        assert_eq!(
19300            items.len(),
19301            2,
19302            "Should have 2 listItems, got {}",
19303            items.len()
19304        );
19305
19306        // First item: text(strong) + hardBreak + text
19307        let p1 = items[0].content.as_ref().unwrap()[0]
19308            .content
19309            .as_ref()
19310            .unwrap();
19311        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
19312        assert_eq!(types1, vec!["text", "hardBreak", "text"]);
19313        assert_eq!(p1[0].text.as_deref(), Some("Bold heading"));
19314        assert_eq!(p1[2].text.as_deref(), Some("Content after break"));
19315
19316        // Second item: text(strong) + hardBreak + text
19317        let p2 = items[1].content.as_ref().unwrap()[0]
19318            .content
19319            .as_ref()
19320            .unwrap();
19321        let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
19322        assert_eq!(types2, vec!["text", "hardBreak", "text"]);
19323        assert_eq!(p2[0].text.as_deref(), Some("Second item"));
19324        assert_eq!(p2[2].text.as_deref(), Some("More content"));
19325    }
19326
19327    #[test]
19328    fn issue_388_bullet_list_with_strong_hardbreak_roundtrips() {
19329        // Bullet list variant of issue #388.
19330        let adf_json = r#"{"version":1,"type":"doc","content":[
19331          {"type":"bulletList","content":[
19332            {"type":"listItem","content":[
19333              {"type":"paragraph","content":[
19334                {"type":"text","text":"First","marks":[{"type":"strong"}]},
19335                {"type":"hardBreak"},
19336                {"type":"text","text":"details"}
19337              ]}
19338            ]},
19339            {"type":"listItem","content":[
19340              {"type":"paragraph","content":[
19341                {"type":"text","text":"Second","marks":[{"type":"em"}]},
19342                {"type":"hardBreak"},
19343                {"type":"text","text":"more details"}
19344              ]}
19345            ]}
19346          ]}
19347        ]}"#;
19348        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19349        let md = adf_to_markdown(&doc).unwrap();
19350        let rt = markdown_to_adf(&md).unwrap();
19351
19352        assert_eq!(rt.content.len(), 1);
19353        assert_eq!(rt.content[0].node_type, "bulletList");
19354        let items = rt.content[0].content.as_ref().unwrap();
19355        assert_eq!(items.len(), 2);
19356
19357        let p1 = items[0].content.as_ref().unwrap()[0]
19358            .content
19359            .as_ref()
19360            .unwrap();
19361        assert_eq!(p1[0].text.as_deref(), Some("First"));
19362        assert_eq!(p1[2].text.as_deref(), Some("details"));
19363
19364        let p2 = items[1].content.as_ref().unwrap()[0]
19365            .content
19366            .as_ref()
19367            .unwrap();
19368        assert_eq!(p2[0].text.as_deref(), Some("Second"));
19369        assert_eq!(p2[2].text.as_deref(), Some("more details"));
19370    }
19371
19372    #[test]
19373    fn issue_388_ordered_list_hardbreak_jfm_indentation() {
19374        // Verify the JFM output has properly indented continuation lines.
19375        let adf_json = r#"{"version":1,"type":"doc","content":[
19376          {"type":"orderedList","attrs":{"order":1},"content":[
19377            {"type":"listItem","content":[
19378              {"type":"paragraph","content":[
19379                {"type":"text","text":"heading","marks":[{"type":"strong"}]},
19380                {"type":"hardBreak"},
19381                {"type":"text","text":"body"}
19382              ]}
19383            ]}
19384          ]}
19385        ]}"#;
19386        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19387        let md = adf_to_markdown(&doc).unwrap();
19388        assert!(
19389            md.contains("1. **heading**\\\n  body"),
19390            "Continuation should be indented, got:\n{md}"
19391        );
19392    }
19393
19394    #[test]
19395    fn issue_388_ordered_list_hardbreak_from_jfm() {
19396        // Direct JFM → ADF: ordered list with hardBreak continuation.
19397        let md = "1. **bold**\\\n  continued\n2. **also bold**\\\n  also continued\n";
19398        let doc = markdown_to_adf(md).unwrap();
19399
19400        assert_eq!(doc.content.len(), 1);
19401        assert_eq!(doc.content[0].node_type, "orderedList");
19402        let items = doc.content[0].content.as_ref().unwrap();
19403        assert_eq!(items.len(), 2);
19404
19405        let p1 = items[0].content.as_ref().unwrap()[0]
19406            .content
19407            .as_ref()
19408            .unwrap();
19409        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
19410        assert_eq!(types1, vec!["text", "hardBreak", "text"]);
19411        assert_eq!(p1[0].text.as_deref(), Some("bold"));
19412        assert_eq!(p1[2].text.as_deref(), Some("continued"));
19413
19414        let p2 = items[1].content.as_ref().unwrap()[0]
19415            .content
19416            .as_ref()
19417            .unwrap();
19418        let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
19419        assert_eq!(types2, vec!["text", "hardBreak", "text"]);
19420    }
19421
19422    #[test]
19423    fn issue_388_bullet_list_hardbreak_from_jfm() {
19424        // Direct JFM → ADF: bullet list with hardBreak continuation.
19425        let md = "- first\\\n  second\n- third\\\n  fourth\n";
19426        let doc = markdown_to_adf(md).unwrap();
19427
19428        assert_eq!(doc.content.len(), 1);
19429        assert_eq!(doc.content[0].node_type, "bulletList");
19430        let items = doc.content[0].content.as_ref().unwrap();
19431        assert_eq!(items.len(), 2);
19432
19433        for (i, expected) in [("first", "second"), ("third", "fourth")]
19434            .iter()
19435            .enumerate()
19436        {
19437            let p = items[i].content.as_ref().unwrap()[0]
19438                .content
19439                .as_ref()
19440                .unwrap();
19441            let types: Vec<&str> = p.iter().map(|n| n.node_type.as_str()).collect();
19442            assert_eq!(types, vec!["text", "hardBreak", "text"]);
19443            assert_eq!(p[0].text.as_deref(), Some(expected.0));
19444            assert_eq!(p[2].text.as_deref(), Some(expected.1));
19445        }
19446    }
19447
19448    #[test]
19449    fn issue_433_heading_hardbreak_roundtrips() {
19450        // Issue #433: hardBreak inside heading splits into heading + paragraph.
19451        let adf_json = r#"{"version":1,"type":"doc","content":[{
19452          "type":"heading",
19453          "attrs":{"level":1},
19454          "content":[
19455            {"type":"text","text":"Line one"},
19456            {"type":"hardBreak"},
19457            {"type":"text","text":"Line two"}
19458          ]
19459        }]}"#;
19460        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19461        let md = adf_to_markdown(&doc).unwrap();
19462        let rt = markdown_to_adf(&md).unwrap();
19463
19464        assert_eq!(
19465            rt.content.len(),
19466            1,
19467            "Should remain a single heading, got {} blocks",
19468            rt.content.len()
19469        );
19470        assert_eq!(rt.content[0].node_type, "heading");
19471        let inlines = rt.content[0].content.as_ref().unwrap();
19472        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19473        assert_eq!(
19474            types,
19475            vec!["text", "hardBreak", "text"],
19476            "hardBreak should be preserved, got: {types:?}"
19477        );
19478        assert_eq!(inlines[0].text.as_deref(), Some("Line one"));
19479        assert_eq!(inlines[2].text.as_deref(), Some("Line two"));
19480    }
19481
19482    #[test]
19483    fn issue_433_heading_hardbreak_jfm_indentation() {
19484        // Verify the JFM output has properly indented continuation lines.
19485        let adf_json = r#"{"version":1,"type":"doc","content":[{
19486          "type":"heading",
19487          "attrs":{"level":2},
19488          "content":[
19489            {"type":"text","text":"Title"},
19490            {"type":"hardBreak"},
19491            {"type":"text","text":"Subtitle"}
19492          ]
19493        }]}"#;
19494        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19495        let md = adf_to_markdown(&doc).unwrap();
19496        assert!(
19497            md.contains("## Title\\\n  Subtitle"),
19498            "Continuation should be indented, got:\n{md}"
19499        );
19500    }
19501
19502    #[test]
19503    fn issue_433_heading_hardbreak_from_jfm() {
19504        // Direct JFM → ADF: heading with hardBreak continuation.
19505        let md = "# First\\\n  Second\n";
19506        let doc = markdown_to_adf(md).unwrap();
19507
19508        assert_eq!(doc.content.len(), 1);
19509        assert_eq!(doc.content[0].node_type, "heading");
19510        let inlines = doc.content[0].content.as_ref().unwrap();
19511        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19512        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19513        assert_eq!(inlines[0].text.as_deref(), Some("First"));
19514        assert_eq!(inlines[2].text.as_deref(), Some("Second"));
19515    }
19516
19517    #[test]
19518    fn issue_433_heading_consecutive_hardbreaks_roundtrip() {
19519        // Consecutive hardBreaks in a heading.
19520        let adf_json = r#"{"version":1,"type":"doc","content":[{
19521          "type":"heading",
19522          "attrs":{"level":3},
19523          "content":[
19524            {"type":"text","text":"A"},
19525            {"type":"hardBreak"},
19526            {"type":"hardBreak"},
19527            {"type":"text","text":"B"}
19528          ]
19529        }]}"#;
19530        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19531        let md = adf_to_markdown(&doc).unwrap();
19532        let rt = markdown_to_adf(&md).unwrap();
19533
19534        assert_eq!(rt.content.len(), 1, "Should remain a single heading");
19535        assert_eq!(rt.content[0].node_type, "heading");
19536        let inlines = rt.content[0].content.as_ref().unwrap();
19537        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19538        assert_eq!(types, vec!["text", "hardBreak", "hardBreak", "text"]);
19539    }
19540
19541    #[test]
19542    fn issue_433_heading_with_strong_and_hardbreak_roundtrips() {
19543        // Heading with strong-marked text + hardBreak + plain text.
19544        let adf_json = r#"{"version":1,"type":"doc","content":[{
19545          "type":"heading",
19546          "attrs":{"level":1},
19547          "content":[
19548            {"type":"text","text":"Bold title","marks":[{"type":"strong"}]},
19549            {"type":"hardBreak"},
19550            {"type":"text","text":"plain continuation"}
19551          ]
19552        }]}"#;
19553        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19554        let md = adf_to_markdown(&doc).unwrap();
19555        let rt = markdown_to_adf(&md).unwrap();
19556
19557        assert_eq!(rt.content.len(), 1);
19558        assert_eq!(rt.content[0].node_type, "heading");
19559        let inlines = rt.content[0].content.as_ref().unwrap();
19560        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19561        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19562        assert_eq!(inlines[0].text.as_deref(), Some("Bold title"));
19563        assert_eq!(inlines[2].text.as_deref(), Some("plain continuation"));
19564    }
19565
19566    #[test]
19567    fn issue_433_heading_with_link_and_hardbreak_roundtrips() {
19568        // Real-world pattern: heading with link + hardBreak + text.
19569        let adf_json = r#"{"version":1,"type":"doc","content":[{
19570          "type":"heading",
19571          "attrs":{"level":1},
19572          "content":[
19573            {"type":"text","text":"Click here","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
19574            {"type":"hardBreak"},
19575            {"type":"text","text":"Subtitle text"}
19576          ]
19577        }]}"#;
19578        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19579        let md = adf_to_markdown(&doc).unwrap();
19580        let rt = markdown_to_adf(&md).unwrap();
19581
19582        assert_eq!(rt.content.len(), 1);
19583        assert_eq!(rt.content[0].node_type, "heading");
19584        let inlines = rt.content[0].content.as_ref().unwrap();
19585        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19586        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19587        assert_eq!(inlines[2].text.as_deref(), Some("Subtitle text"));
19588    }
19589
19590    #[test]
19591    fn has_trailing_hard_break_backslash() {
19592        assert!(has_trailing_hard_break("text\\"));
19593        assert!(has_trailing_hard_break("**bold**\\"));
19594    }
19595
19596    #[test]
19597    fn has_trailing_hard_break_trailing_spaces() {
19598        assert!(has_trailing_hard_break("text  "));
19599        assert!(has_trailing_hard_break("word   "));
19600    }
19601
19602    #[test]
19603    fn has_trailing_hard_break_false() {
19604        assert!(!has_trailing_hard_break("plain text"));
19605        assert!(!has_trailing_hard_break("text "));
19606        assert!(!has_trailing_hard_break(""));
19607    }
19608
19609    #[test]
19610    fn collect_hardbreak_continuations_collects_indented() {
19611        // A line ending with `\` followed by 2-space-indented continuation.
19612        // Only one line is collected because the result no longer ends with `\`.
19613        let input = "first\\\n  second\n  third\n";
19614        let mut parser = MarkdownParser::new(input);
19615        parser.advance(); // skip first line
19616        let mut text = "first\\".to_string();
19617        parser.collect_hardbreak_continuations(&mut text);
19618        assert_eq!(text, "first\\\nsecond");
19619    }
19620
19621    #[test]
19622    fn collect_hardbreak_continuations_stops_at_non_indented() {
19623        let input = "first\\\nnot indented\n";
19624        let mut parser = MarkdownParser::new(input);
19625        parser.advance();
19626        let mut text = "first\\".to_string();
19627        parser.collect_hardbreak_continuations(&mut text);
19628        // Should NOT collect the non-indented line
19629        assert_eq!(text, "first\\");
19630    }
19631
19632    #[test]
19633    fn collect_hardbreak_continuations_no_trailing_break() {
19634        // If the text doesn't end with a hardBreak marker, nothing is collected.
19635        let input = "plain\n  indented\n";
19636        let mut parser = MarkdownParser::new(input);
19637        parser.advance();
19638        let mut text = "plain".to_string();
19639        parser.collect_hardbreak_continuations(&mut text);
19640        assert_eq!(text, "plain");
19641    }
19642
19643    #[test]
19644    fn collect_hardbreak_continuations_chained() {
19645        // Multiple continuation lines chained via repeated hardBreaks.
19646        let input = "a\\\n  b\\\n  c\\\n  d\n";
19647        let mut parser = MarkdownParser::new(input);
19648        parser.advance();
19649        let mut text = "a\\".to_string();
19650        parser.collect_hardbreak_continuations(&mut text);
19651        assert_eq!(text, "a\\\nb\\\nc\\\nd");
19652    }
19653
19654    #[test]
19655    fn collect_hardbreak_continuations_stops_before_image_line() {
19656        // An indented continuation that starts with `![` (mediaSingle syntax)
19657        // must NOT be swallowed as a paragraph continuation (issue #490).
19658        let input = "text\\\n  ![](url){type=file id=x}\n";
19659        let mut parser = MarkdownParser::new(input);
19660        parser.advance(); // skip first line
19661        let mut text = "text\\".to_string();
19662        parser.collect_hardbreak_continuations(&mut text);
19663        // The image line should NOT have been consumed.
19664        assert_eq!(text, "text\\");
19665        // Parser should still be on the image line (not past it).
19666        assert!(!parser.at_end());
19667        assert!(parser.current_line().contains("![](url)"));
19668    }
19669
19670    #[test]
19671    fn is_block_level_continuation_marker_positive_cases() {
19672        // Each marker that forces `collect_hardbreak_continuations` to stop.
19673        assert!(is_block_level_continuation_marker("![](url)"));
19674        assert!(is_block_level_continuation_marker("```ruby"));
19675        assert!(is_block_level_continuation_marker(":::panel{type=info}"));
19676    }
19677
19678    #[test]
19679    fn is_block_level_continuation_marker_negative_cases() {
19680        // Plain continuation text must NOT look like a block-level marker.
19681        assert!(!is_block_level_continuation_marker("plain text"));
19682        assert!(!is_block_level_continuation_marker("- nested item"));
19683        assert!(!is_block_level_continuation_marker("continuation\\"));
19684        assert!(!is_block_level_continuation_marker(""));
19685        // Double-colon `::` is not a container directive.
19686        assert!(!is_block_level_continuation_marker("::partial"));
19687        // Single backticks are inline code, not a fence.
19688        assert!(!is_block_level_continuation_marker("`inline`"));
19689    }
19690
19691    #[test]
19692    fn collect_hardbreak_continuations_stops_before_code_fence() {
19693        // Issue #552: An indented continuation that opens a fenced code block
19694        // must NOT be swallowed as a paragraph continuation — it has to stay
19695        // available for `try_code_block` on the next parse iteration.
19696        let input = "text\\\n  ```ruby\n  Foo::Bar::Baz\n  ```\n";
19697        let mut parser = MarkdownParser::new(input);
19698        parser.advance();
19699        let mut text = "text\\".to_string();
19700        parser.collect_hardbreak_continuations(&mut text);
19701        assert_eq!(text, "text\\");
19702        assert!(!parser.at_end());
19703        assert!(parser.current_line().starts_with("  ```"));
19704    }
19705
19706    #[test]
19707    fn collect_hardbreak_continuations_stops_before_container_directive() {
19708        // Issue #552: An indented continuation that opens a `:::` container
19709        // directive (panel, expand, etc.) must also stay available for the
19710        // directive parser.
19711        let input = "text\\\n  :::panel{type=info}\n  body\n  :::\n";
19712        let mut parser = MarkdownParser::new(input);
19713        parser.advance();
19714        let mut text = "text\\".to_string();
19715        parser.collect_hardbreak_continuations(&mut text);
19716        assert_eq!(text, "text\\");
19717        assert!(!parser.at_end());
19718        assert!(parser.current_line().contains(":::panel"));
19719    }
19720
19721    #[test]
19722    fn collect_hardbreak_continuations_stops_before_indented_code_fence() {
19723        // Variant: extra leading whitespace on the code-fence line (so the
19724        // stripped tail is `  ```` rather than a bare ` ``` `) must still be
19725        // recognised by the `trim_start().starts_with("```")` check.
19726        let input = "text\\\n     ```text\n     :fire:\n     ```\n";
19727        let mut parser = MarkdownParser::new(input);
19728        parser.advance();
19729        let mut text = "text\\".to_string();
19730        parser.collect_hardbreak_continuations(&mut text);
19731        assert_eq!(text, "text\\");
19732        assert!(!parser.at_end());
19733        assert!(parser.current_line().contains("```text"));
19734    }
19735
19736    #[test]
19737    fn ordered_list_with_sub_content_after_hardbreak() {
19738        // Exercises the sub-content collection loop in parse_ordered_list
19739        // (lines 339-347) with a hardBreak item that also has a nested list.
19740        let adf_json = r#"{"version":1,"type":"doc","content":[
19741          {"type":"orderedList","attrs":{"order":1},"content":[
19742            {"type":"listItem","content":[
19743              {"type":"paragraph","content":[
19744                {"type":"text","text":"parent"},
19745                {"type":"hardBreak"},
19746                {"type":"text","text":"continued"}
19747              ]},
19748              {"type":"bulletList","content":[
19749                {"type":"listItem","content":[
19750                  {"type":"paragraph","content":[
19751                    {"type":"text","text":"child"}
19752                  ]}
19753                ]}
19754              ]}
19755            ]}
19756          ]}
19757        ]}"#;
19758        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19759        let md = adf_to_markdown(&doc).unwrap();
19760        let rt = markdown_to_adf(&md).unwrap();
19761
19762        assert_eq!(rt.content.len(), 1);
19763        assert_eq!(rt.content[0].node_type, "orderedList");
19764        let item_content = rt.content[0].content.as_ref().unwrap()[0]
19765            .content
19766            .as_ref()
19767            .unwrap();
19768        // Paragraph with hardBreak
19769        let p = item_content[0].content.as_ref().unwrap();
19770        let types: Vec<&str> = p.iter().map(|n| n.node_type.as_str()).collect();
19771        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19772        assert_eq!(p[0].text.as_deref(), Some("parent"));
19773        assert_eq!(p[2].text.as_deref(), Some("continued"));
19774        // Nested bullet list
19775        assert_eq!(item_content[1].node_type, "bulletList");
19776    }
19777
19778    #[test]
19779    fn render_list_item_content_no_content() {
19780        // A listItem with content: None should produce just a newline.
19781        let item = AdfNode {
19782            node_type: "listItem".to_string(),
19783            attrs: None,
19784            content: None,
19785            text: None,
19786            marks: None,
19787            local_id: None,
19788            parameters: None,
19789        };
19790        let mut output = String::new();
19791        let opts = RenderOptions::default();
19792        render_list_item_content(&item, &mut output, &opts);
19793        assert_eq!(output, "\n");
19794    }
19795
19796    #[test]
19797    fn render_list_item_content_empty_content() {
19798        // A listItem with content: Some(vec![]) should produce just a newline.
19799        let item = AdfNode::list_item(vec![]);
19800        let mut output = String::new();
19801        let opts = RenderOptions::default();
19802        render_list_item_content(&item, &mut output, &opts);
19803        assert_eq!(output, "\n");
19804    }
19805
19806    #[test]
19807    fn plus_bullet_paragraph_roundtrips() {
19808        // Paragraph starting with "+ " must round-trip without becoming
19809        // a bullet list.
19810        let adf_json = r#"{"version":1,"type":"doc","content":[
19811          {"type":"paragraph","content":[
19812            {"type":"text","text":"+ plus"}
19813          ]}
19814        ]}"#;
19815        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19816        let md = adf_to_markdown(&doc).unwrap();
19817        let rt = markdown_to_adf(&md).unwrap();
19818        assert_eq!(rt.content[0].node_type, "paragraph");
19819        assert_eq!(
19820            rt.content[0].content.as_ref().unwrap()[0]
19821                .text
19822                .as_deref()
19823                .unwrap(),
19824            "+ plus"
19825        );
19826    }
19827
19828    // ---- Issue #430 tests: mediaSingle inside listItem ----
19829
19830    #[test]
19831    fn issue_430_file_media_in_bullet_list_roundtrip() {
19832        // Issue #430: mediaSingle (type:file) as direct child of listItem
19833        // in a bulletList must survive round-trip.
19834        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19835          {"type":"listItem","content":[{
19836            "type":"mediaSingle",
19837            "attrs":{"layout":"center","width":1009,"widthType":"pixel"},
19838            "content":[{
19839              "type":"media",
19840              "attrs":{"collection":"contentId-123","height":576,"id":"00066e8e-554e-4d7e-af59-a0ef2888bdb6","type":"file","width":1009}
19841            }]
19842          }]}
19843        ]}]}"#;
19844        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19845        let md = adf_to_markdown(&doc).unwrap();
19846        let rt = markdown_to_adf(&md).unwrap();
19847
19848        let list = &rt.content[0];
19849        assert_eq!(list.node_type, "bulletList");
19850        let item = &list.content.as_ref().unwrap()[0];
19851        assert_eq!(item.node_type, "listItem");
19852        let ms = &item.content.as_ref().unwrap()[0];
19853        assert_eq!(ms.node_type, "mediaSingle");
19854        let ms_attrs = ms.attrs.as_ref().unwrap();
19855        assert_eq!(ms_attrs["layout"], "center");
19856        assert_eq!(ms_attrs["width"], 1009);
19857        assert_eq!(ms_attrs["widthType"], "pixel");
19858        let media = &ms.content.as_ref().unwrap()[0];
19859        assert_eq!(media.node_type, "media");
19860        let m_attrs = media.attrs.as_ref().unwrap();
19861        assert_eq!(m_attrs["type"], "file");
19862        assert_eq!(m_attrs["id"], "00066e8e-554e-4d7e-af59-a0ef2888bdb6");
19863        assert_eq!(m_attrs["collection"], "contentId-123");
19864        assert_eq!(m_attrs["height"], 576);
19865        assert_eq!(m_attrs["width"], 1009);
19866    }
19867
19868    #[test]
19869    fn issue_430_file_media_in_ordered_list_roundtrip() {
19870        // Same as above but inside an orderedList.
19871        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
19872          {"type":"listItem","content":[{
19873            "type":"mediaSingle",
19874            "attrs":{"layout":"center"},
19875            "content":[{
19876              "type":"media",
19877              "attrs":{"type":"file","id":"abc-123","collection":"contentId-456","height":100,"width":200}
19878            }]
19879          }]}
19880        ]}]}"#;
19881        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19882        let md = adf_to_markdown(&doc).unwrap();
19883        let rt = markdown_to_adf(&md).unwrap();
19884
19885        let list = &rt.content[0];
19886        assert_eq!(list.node_type, "orderedList");
19887        let item = &list.content.as_ref().unwrap()[0];
19888        assert_eq!(item.node_type, "listItem");
19889        let ms = &item.content.as_ref().unwrap()[0];
19890        assert_eq!(ms.node_type, "mediaSingle");
19891        let media = &ms.content.as_ref().unwrap()[0];
19892        assert_eq!(media.node_type, "media");
19893        let m_attrs = media.attrs.as_ref().unwrap();
19894        assert_eq!(m_attrs["type"], "file");
19895        assert_eq!(m_attrs["id"], "abc-123");
19896        assert_eq!(m_attrs["collection"], "contentId-456");
19897    }
19898
19899    #[test]
19900    fn issue_430_external_media_in_bullet_list_roundtrip() {
19901        // External image (type:external) inside a bullet list item.
19902        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19903          {"type":"listItem","content":[{
19904            "type":"mediaSingle",
19905            "attrs":{"layout":"center"},
19906            "content":[{
19907              "type":"media",
19908              "attrs":{"type":"external","url":"https://example.com/img.png","alt":"Photo"}
19909            }]
19910          }]}
19911        ]}]}"#;
19912        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19913        let md = adf_to_markdown(&doc).unwrap();
19914        let rt = markdown_to_adf(&md).unwrap();
19915
19916        let list = &rt.content[0];
19917        assert_eq!(list.node_type, "bulletList");
19918        let item = &list.content.as_ref().unwrap()[0];
19919        let ms = &item.content.as_ref().unwrap()[0];
19920        assert_eq!(ms.node_type, "mediaSingle");
19921        let media = &ms.content.as_ref().unwrap()[0];
19922        assert_eq!(media.node_type, "media");
19923        let m_attrs = media.attrs.as_ref().unwrap();
19924        assert_eq!(m_attrs["type"], "external");
19925        assert_eq!(m_attrs["url"], "https://example.com/img.png");
19926    }
19927
19928    #[test]
19929    fn issue_430_media_with_paragraph_siblings_in_list_item() {
19930        // listItem containing a paragraph followed by a mediaSingle.
19931        // Both children must survive round-trip.
19932        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19933          {"type":"listItem","content":[
19934            {"type":"paragraph","content":[{"type":"text","text":"Caption:"}]},
19935            {"type":"mediaSingle","attrs":{"layout":"center"},
19936             "content":[{"type":"media","attrs":{"type":"file","id":"img-001","collection":"col-1","height":50,"width":100}}]}
19937          ]}
19938        ]}]}"#;
19939        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19940        let md = adf_to_markdown(&doc).unwrap();
19941        let rt = markdown_to_adf(&md).unwrap();
19942
19943        let item = &rt.content[0].content.as_ref().unwrap()[0];
19944        let children = item.content.as_ref().unwrap();
19945        assert_eq!(children.len(), 2, "expected 2 children in listItem");
19946        assert_eq!(children[0].node_type, "paragraph");
19947        assert_eq!(children[1].node_type, "mediaSingle");
19948        let media = &children[1].content.as_ref().unwrap()[0];
19949        assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-001");
19950    }
19951
19952    #[test]
19953    fn issue_430_multiple_media_in_list_items() {
19954        // Multiple list items each containing mediaSingle.
19955        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19956          {"type":"listItem","content":[{
19957            "type":"mediaSingle","attrs":{"layout":"center"},
19958            "content":[{"type":"media","attrs":{"type":"file","id":"img-a","collection":"c1","height":10,"width":20}}]
19959          }]},
19960          {"type":"listItem","content":[{
19961            "type":"mediaSingle","attrs":{"layout":"center"},
19962            "content":[{"type":"media","attrs":{"type":"file","id":"img-b","collection":"c2","height":30,"width":40}}]
19963          }]}
19964        ]}]}"#;
19965        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19966        let md = adf_to_markdown(&doc).unwrap();
19967        let rt = markdown_to_adf(&md).unwrap();
19968
19969        let items = rt.content[0].content.as_ref().unwrap();
19970        assert_eq!(items.len(), 2);
19971        for (i, expected_id) in [("img-a", "c1"), ("img-b", "c2")].iter().enumerate() {
19972            let ms = &items[i].content.as_ref().unwrap()[0];
19973            assert_eq!(ms.node_type, "mediaSingle");
19974            let m_attrs = ms.content.as_ref().unwrap()[0].attrs.as_ref().unwrap();
19975            assert_eq!(m_attrs["id"], expected_id.0);
19976            assert_eq!(m_attrs["collection"], expected_id.1);
19977        }
19978    }
19979
19980    #[test]
19981    fn issue_430_jfm_to_adf_media_in_bullet_item() {
19982        // Parse JFM directly: image syntax on the first line of a bullet item
19983        // must produce mediaSingle, not a paragraph with corrupted text.
19984        let md = "- ![](){type=file id=test-id collection=col-1 height=100 width=200}\n";
19985        let doc = markdown_to_adf(md).unwrap();
19986
19987        let list = &doc.content[0];
19988        assert_eq!(list.node_type, "bulletList");
19989        let item = &list.content.as_ref().unwrap()[0];
19990        let ms = &item.content.as_ref().unwrap()[0];
19991        assert_eq!(
19992            ms.node_type, "mediaSingle",
19993            "expected mediaSingle, got {}",
19994            ms.node_type
19995        );
19996        let media = &ms.content.as_ref().unwrap()[0];
19997        assert_eq!(media.node_type, "media");
19998        let m_attrs = media.attrs.as_ref().unwrap();
19999        assert_eq!(m_attrs["type"], "file");
20000        assert_eq!(m_attrs["id"], "test-id");
20001    }
20002
20003    #[test]
20004    fn issue_430_jfm_to_adf_media_in_ordered_item() {
20005        // Parse JFM directly: image syntax on the first line of an ordered list item.
20006        let md = "1. ![alt text](https://example.com/photo.jpg)\n";
20007        let doc = markdown_to_adf(md).unwrap();
20008
20009        let list = &doc.content[0];
20010        assert_eq!(list.node_type, "orderedList");
20011        let item = &list.content.as_ref().unwrap()[0];
20012        let ms = &item.content.as_ref().unwrap()[0];
20013        assert_eq!(
20014            ms.node_type, "mediaSingle",
20015            "expected mediaSingle, got {}",
20016            ms.node_type
20017        );
20018    }
20019
20020    #[test]
20021    fn issue_430_media_then_paragraph_in_bullet_list_roundtrip() {
20022        // listItem with mediaSingle as first child followed by a paragraph.
20023        // Exercises the sub_lines non-empty path when first_node is mediaSingle.
20024        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20025          {"type":"listItem","content":[
20026            {"type":"mediaSingle","attrs":{"layout":"center"},
20027             "content":[{"type":"media","attrs":{"type":"file","id":"img-first","collection":"col-1","height":50,"width":100}}]},
20028            {"type":"paragraph","content":[{"type":"text","text":"Caption below"}]}
20029          ]}
20030        ]}]}"#;
20031        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20032        let md = adf_to_markdown(&doc).unwrap();
20033        let rt = markdown_to_adf(&md).unwrap();
20034
20035        let item = &rt.content[0].content.as_ref().unwrap()[0];
20036        let children = item.content.as_ref().unwrap();
20037        assert_eq!(children.len(), 2, "expected 2 children in listItem");
20038        assert_eq!(children[0].node_type, "mediaSingle");
20039        let media = &children[0].content.as_ref().unwrap()[0];
20040        assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-first");
20041        assert_eq!(children[1].node_type, "paragraph");
20042    }
20043
20044    #[test]
20045    fn issue_430_media_then_paragraph_in_ordered_list_roundtrip() {
20046        // Same as above but for ordered lists.
20047        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
20048          {"type":"listItem","content":[
20049            {"type":"mediaSingle","attrs":{"layout":"center"},
20050             "content":[{"type":"media","attrs":{"type":"file","id":"img-ord","collection":"col-2","height":60,"width":120}}]},
20051            {"type":"paragraph","content":[{"type":"text","text":"Description"}]}
20052          ]}
20053        ]}]}"#;
20054        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20055        let md = adf_to_markdown(&doc).unwrap();
20056        let rt = markdown_to_adf(&md).unwrap();
20057
20058        let item = &rt.content[0].content.as_ref().unwrap()[0];
20059        let children = item.content.as_ref().unwrap();
20060        assert_eq!(children.len(), 2, "expected 2 children in listItem");
20061        assert_eq!(children[0].node_type, "mediaSingle");
20062        assert_eq!(children[1].node_type, "paragraph");
20063    }
20064
20065    #[test]
20066    fn issue_430_external_media_with_width_type_roundtrip() {
20067        // External image with widthType attr must survive round-trip.
20068        let adf_json = r#"{"version":1,"type":"doc","content":[{
20069          "type":"mediaSingle",
20070          "attrs":{"layout":"wide","width":800,"widthType":"pixel"},
20071          "content":[{
20072            "type":"media",
20073            "attrs":{"type":"external","url":"https://example.com/photo.png","alt":"wide photo"}
20074          }]
20075        }]}"#;
20076        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20077        let md = adf_to_markdown(&doc).unwrap();
20078        assert!(
20079            md.contains("widthType=pixel"),
20080            "expected widthType=pixel in markdown, got: {md}"
20081        );
20082        let rt = markdown_to_adf(&md).unwrap();
20083        let ms = &rt.content[0];
20084        assert_eq!(ms.node_type, "mediaSingle");
20085        let ms_attrs = ms.attrs.as_ref().unwrap();
20086        assert_eq!(ms_attrs["widthType"], "pixel");
20087        assert_eq!(ms_attrs["width"], 800);
20088        assert_eq!(ms_attrs["layout"], "wide");
20089    }
20090
20091    // ── Issue #490: mediaSingle after hardBreak in listItem ─────
20092
20093    #[test]
20094    fn issue_490_paragraph_with_hardbreak_then_media_single_roundtrip() {
20095        // Reproducer from issue #490: paragraph with trailing hardBreak
20096        // followed by mediaSingle inside a listItem.
20097        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20098          {"type":"listItem","content":[
20099            {"type":"paragraph","content":[
20100              {"type":"text","text":"Item with image:"},
20101              {"type":"hardBreak"}
20102            ]},
20103            {"type":"mediaSingle","attrs":{"layout":"center","width":400,"widthType":"pixel"},
20104             "content":[{"type":"media","attrs":{
20105               "id":"aabbccdd-1234-5678-abcd-aabbccdd1234",
20106               "type":"file",
20107               "collection":"contentId-123456",
20108               "width":800,
20109               "height":600
20110             }}]}
20111          ]}
20112        ]}]}"#;
20113        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20114        let md = adf_to_markdown(&doc).unwrap();
20115        let rt = markdown_to_adf(&md).unwrap();
20116
20117        let item = &rt.content[0].content.as_ref().unwrap()[0];
20118        let children = item.content.as_ref().unwrap();
20119        assert_eq!(children.len(), 2, "expected 2 children in listItem");
20120        assert_eq!(children[0].node_type, "paragraph");
20121        assert_eq!(
20122            children[1].node_type, "mediaSingle",
20123            "expected mediaSingle, got {:?}",
20124            children[1].node_type
20125        );
20126        let media = &children[1].content.as_ref().unwrap()[0];
20127        let m_attrs = media.attrs.as_ref().unwrap();
20128        assert_eq!(m_attrs["id"], "aabbccdd-1234-5678-abcd-aabbccdd1234");
20129        assert_eq!(m_attrs["collection"], "contentId-123456");
20130        assert_eq!(m_attrs["height"], 600);
20131        assert_eq!(m_attrs["width"], 800);
20132    }
20133
20134    #[test]
20135    fn issue_490_paragraph_with_hardbreak_then_media_single_ordered_list() {
20136        // Same scenario but in an ordered list.
20137        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
20138          {"type":"listItem","content":[
20139            {"type":"paragraph","content":[
20140              {"type":"text","text":"Step with screenshot:"},
20141              {"type":"hardBreak"}
20142            ]},
20143            {"type":"mediaSingle","attrs":{"layout":"center"},
20144             "content":[{"type":"media","attrs":{
20145               "id":"ord-media-id","type":"file","collection":"col-ord","width":640,"height":480
20146             }}]}
20147          ]}
20148        ]}]}"#;
20149        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20150        let md = adf_to_markdown(&doc).unwrap();
20151        let rt = markdown_to_adf(&md).unwrap();
20152
20153        let item = &rt.content[0].content.as_ref().unwrap()[0];
20154        let children = item.content.as_ref().unwrap();
20155        assert_eq!(children.len(), 2, "expected 2 children in listItem");
20156        assert_eq!(children[0].node_type, "paragraph");
20157        assert_eq!(children[1].node_type, "mediaSingle");
20158        let media = &children[1].content.as_ref().unwrap()[0];
20159        assert_eq!(media.attrs.as_ref().unwrap()["id"], "ord-media-id");
20160    }
20161
20162    #[test]
20163    fn issue_490_hardbreak_continuation_does_not_swallow_media_line() {
20164        // Directly tests that collect_hardbreak_continuations stops before
20165        // an indented mediaSingle line.
20166        let md = "- Item with image:\\\n  ![](){type=file id=test-490 collection=col height=100 width=200}\n";
20167        let doc = markdown_to_adf(md).unwrap();
20168
20169        let item = &doc.content[0].content.as_ref().unwrap()[0];
20170        let children = item.content.as_ref().unwrap();
20171        assert_eq!(children.len(), 2, "expected 2 children in listItem");
20172        assert_eq!(children[0].node_type, "paragraph");
20173        assert_eq!(
20174            children[1].node_type, "mediaSingle",
20175            "expected mediaSingle as second child, got {:?}",
20176            children[1].node_type
20177        );
20178        let media = &children[1].content.as_ref().unwrap()[0];
20179        assert_eq!(media.attrs.as_ref().unwrap()["id"], "test-490");
20180    }
20181
20182    #[test]
20183    fn issue_490_hardbreak_continuation_still_works_for_text() {
20184        // Ensure regular hardBreak continuations still work after the fix.
20185        let md = "- first line\\\n  second line\n";
20186        let doc = markdown_to_adf(md).unwrap();
20187
20188        let item = &doc.content[0].content.as_ref().unwrap()[0];
20189        let children = item.content.as_ref().unwrap();
20190        assert_eq!(
20191            children.len(),
20192            1,
20193            "expected 1 child (paragraph) in listItem"
20194        );
20195        assert_eq!(children[0].node_type, "paragraph");
20196        let inlines = children[0].content.as_ref().unwrap();
20197        // Should contain: text("first line"), hardBreak, text("second line")
20198        assert_eq!(inlines.len(), 3);
20199        assert_eq!(inlines[0].node_type, "text");
20200        assert_eq!(inlines[1].node_type, "hardBreak");
20201        assert_eq!(inlines[2].node_type, "text");
20202    }
20203
20204    #[test]
20205    fn issue_490_external_media_after_hardbreak_roundtrip() {
20206        // External image (URL-based) after a paragraph with hardBreak.
20207        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20208          {"type":"listItem","content":[
20209            {"type":"paragraph","content":[
20210              {"type":"text","text":"See image:"},
20211              {"type":"hardBreak"}
20212            ]},
20213            {"type":"mediaSingle","attrs":{"layout":"center"},
20214             "content":[{"type":"media","attrs":{
20215               "type":"external","url":"https://example.com/photo.png","alt":"photo"
20216             }}]}
20217          ]}
20218        ]}]}"#;
20219        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20220        let md = adf_to_markdown(&doc).unwrap();
20221        let rt = markdown_to_adf(&md).unwrap();
20222
20223        let item = &rt.content[0].content.as_ref().unwrap()[0];
20224        let children = item.content.as_ref().unwrap();
20225        assert_eq!(children.len(), 2);
20226        assert_eq!(children[0].node_type, "paragraph");
20227        assert_eq!(children[1].node_type, "mediaSingle");
20228        let media = &children[1].content.as_ref().unwrap()[0];
20229        let m_attrs = media.attrs.as_ref().unwrap();
20230        assert_eq!(m_attrs["url"], "https://example.com/photo.png");
20231    }
20232
20233    #[test]
20234    fn issue_490_multiple_hardbreaks_then_media_single() {
20235        // Paragraph with multiple hardBreaks, then mediaSingle.
20236        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20237          {"type":"listItem","content":[
20238            {"type":"paragraph","content":[
20239              {"type":"text","text":"line one"},
20240              {"type":"hardBreak"},
20241              {"type":"text","text":"line two"},
20242              {"type":"hardBreak"}
20243            ]},
20244            {"type":"mediaSingle","attrs":{"layout":"center"},
20245             "content":[{"type":"media","attrs":{
20246               "type":"file","id":"multi-hb","collection":"col-m","width":320,"height":240
20247             }}]}
20248          ]}
20249        ]}]}"#;
20250        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20251        let md = adf_to_markdown(&doc).unwrap();
20252        let rt = markdown_to_adf(&md).unwrap();
20253
20254        let item = &rt.content[0].content.as_ref().unwrap()[0];
20255        let children = item.content.as_ref().unwrap();
20256        assert_eq!(children.len(), 2, "expected paragraph + mediaSingle");
20257        assert_eq!(children[0].node_type, "paragraph");
20258        assert_eq!(children[1].node_type, "mediaSingle");
20259        let media = &children[1].content.as_ref().unwrap()[0];
20260        assert_eq!(media.attrs.as_ref().unwrap()["id"], "multi-hb");
20261    }
20262
20263    // ── Issue #525: listItem localId dropped when content includes mediaSingle ──
20264
20265    #[test]
20266    fn issue_525_listitem_localid_with_mediasingle_roundtrip() {
20267        // Exact reproducer from issue #525: listItem with UUID localId whose
20268        // content includes mediaSingle + paragraph + nested bulletList.
20269        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"}]}]}]}]}]}]}"#;
20270        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20271        let md = adf_to_markdown(&doc).unwrap();
20272        let rt = markdown_to_adf(&md).unwrap();
20273
20274        let list = &rt.content[0];
20275        assert_eq!(list.node_type, "bulletList");
20276        let item = &list.content.as_ref().unwrap()[0];
20277        // The localId must be preserved on the listItem.
20278        let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20279        assert_eq!(
20280            item_attrs["localId"], "aabbccdd-1234-5678-abcd-000000000001",
20281            "listItem localId must survive round-trip"
20282        );
20283        let children = item.content.as_ref().unwrap();
20284        assert_eq!(
20285            children.len(),
20286            3,
20287            "expected mediaSingle + paragraph + bulletList"
20288        );
20289        assert_eq!(children[0].node_type, "mediaSingle");
20290        assert_eq!(children[1].node_type, "paragraph");
20291        assert_eq!(children[2].node_type, "bulletList");
20292    }
20293
20294    #[test]
20295    fn issue_525_listitem_localid_with_mediasingle_only() {
20296        // Minimal case: listItem with localId whose sole child is mediaSingle.
20297        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20298          {"type":"listItem","attrs":{"localId":"li-media-only"},"content":[
20299            {"type":"mediaSingle","attrs":{"layout":"center"},
20300             "content":[{"type":"media","attrs":{"type":"file","id":"m-001","collection":"c1","height":50,"width":100}}]}
20301          ]}
20302        ]}]}"#;
20303        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20304        let md = adf_to_markdown(&doc).unwrap();
20305        let rt = markdown_to_adf(&md).unwrap();
20306
20307        let item = &rt.content[0].content.as_ref().unwrap()[0];
20308        let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20309        assert_eq!(
20310            item_attrs["localId"], "li-media-only",
20311            "listItem localId must survive when sole child is mediaSingle"
20312        );
20313        assert_eq!(item.content.as_ref().unwrap()[0].node_type, "mediaSingle");
20314    }
20315
20316    #[test]
20317    fn issue_525_listitem_localid_with_external_media() {
20318        // External image (URL-based) as first child with listItem localId.
20319        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20320          {"type":"listItem","attrs":{"localId":"li-ext-media"},"content":[
20321            {"type":"mediaSingle","attrs":{"layout":"center"},
20322             "content":[{"type":"media","attrs":{"type":"external","url":"https://example.com/img.png","alt":"photo"}}]}
20323          ]}
20324        ]}]}"#;
20325        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20326        let md = adf_to_markdown(&doc).unwrap();
20327        let rt = markdown_to_adf(&md).unwrap();
20328
20329        let item = &rt.content[0].content.as_ref().unwrap()[0];
20330        let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20331        assert_eq!(
20332            item_attrs["localId"], "li-ext-media",
20333            "listItem localId must survive with external mediaSingle"
20334        );
20335    }
20336
20337    #[test]
20338    fn issue_525_listitem_localid_with_mediasingle_in_ordered_list() {
20339        // Same bug in an ordered list.
20340        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
20341          {"type":"listItem","attrs":{"localId":"li-ord-media"},"content":[
20342            {"type":"mediaSingle","attrs":{"layout":"center","width":200,"widthType":"pixel"},
20343             "content":[{"type":"media","attrs":{"type":"file","id":"ord-m-001","collection":"col-ord","height":80,"width":160}}]},
20344            {"type":"paragraph","content":[{"type":"text","text":"ordered item text"}]}
20345          ]}
20346        ]}]}"#;
20347        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20348        let md = adf_to_markdown(&doc).unwrap();
20349        let rt = markdown_to_adf(&md).unwrap();
20350
20351        let item = &rt.content[0].content.as_ref().unwrap()[0];
20352        let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20353        assert_eq!(
20354            item_attrs["localId"], "li-ord-media",
20355            "listItem localId must survive in ordered list with mediaSingle"
20356        );
20357        let children = item.content.as_ref().unwrap();
20358        assert_eq!(children[0].node_type, "mediaSingle");
20359        assert_eq!(children[1].node_type, "paragraph");
20360    }
20361
20362    #[test]
20363    fn issue_525_jfm_localid_on_mediasingle_line_parses_correctly() {
20364        // Verify JFM→ADF: trailing {localId=...} on a mediaSingle line
20365        // is assigned to the listItem, not the media node.
20366        let md = "- ![](){type=file id=test-525 collection=col height=100 width=200 mediaWidth=100 widthType=pixel} {localId=li-jfm-525}\n";
20367        let doc = markdown_to_adf(md).unwrap();
20368
20369        let item = &doc.content[0].content.as_ref().unwrap()[0];
20370        let item_attrs = item
20371            .attrs
20372            .as_ref()
20373            .expect("listItem attrs must be present from JFM");
20374        assert_eq!(item_attrs["localId"], "li-jfm-525");
20375        assert_eq!(item.content.as_ref().unwrap()[0].node_type, "mediaSingle");
20376    }
20377
20378    #[test]
20379    fn issue_525_encoding_emits_localid_on_mediasingle_line() {
20380        // Verify the ADF→JFM encoding: localId appears on the same line
20381        // as the mediaSingle image syntax.
20382        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20383          {"type":"listItem","attrs":{"localId":"li-emit-check"},"content":[
20384            {"type":"mediaSingle","attrs":{"layout":"center"},
20385             "content":[{"type":"media","attrs":{"type":"file","id":"m-emit","collection":"c-emit","height":10,"width":20}}]}
20386          ]}
20387        ]}]}"#;
20388        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20389        let md = adf_to_markdown(&doc).unwrap();
20390        assert!(
20391            md.contains("{localId=li-emit-check}"),
20392            "expected localId in JFM output, got: {md}"
20393        );
20394        // The localId must be on the same line as the image
20395        for line in md.lines() {
20396            if line.contains("![") {
20397                assert!(
20398                    line.contains("localId=li-emit-check"),
20399                    "localId must be on the same line as the image: {line}"
20400                );
20401            }
20402        }
20403    }
20404
20405    // ── Placeholder node tests ────────────────────────────────────
20406
20407    #[test]
20408    fn adf_placeholder_to_markdown() {
20409        let doc = AdfDocument {
20410            version: 1,
20411            doc_type: "doc".to_string(),
20412            content: vec![AdfNode::paragraph(vec![AdfNode::placeholder(
20413                "Type something here",
20414            )])],
20415        };
20416        let md = adf_to_markdown(&doc).unwrap();
20417        assert!(
20418            md.contains(":placeholder[Type something here]"),
20419            "expected :placeholder directive, got: {md}"
20420        );
20421    }
20422
20423    #[test]
20424    fn markdown_placeholder_to_adf() {
20425        let doc = markdown_to_adf("Before :placeholder[Enter name] after").unwrap();
20426        let content = doc.content[0].content.as_ref().unwrap();
20427        assert_eq!(content[1].node_type, "placeholder");
20428        let attrs = content[1].attrs.as_ref().unwrap();
20429        assert_eq!(attrs["text"], "Enter name");
20430    }
20431
20432    #[test]
20433    fn placeholder_round_trip() {
20434        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"placeholder","attrs":{"text":"Type something here"}}]}]}"#;
20435        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20436        let md = adf_to_markdown(&doc).unwrap();
20437        let rt = markdown_to_adf(&md).unwrap();
20438        let content = rt.content[0].content.as_ref().unwrap();
20439        assert_eq!(content.len(), 1);
20440        assert_eq!(content[0].node_type, "placeholder");
20441        let attrs = content[0].attrs.as_ref().unwrap();
20442        assert_eq!(attrs["text"], "Type something here");
20443    }
20444
20445    #[test]
20446    fn placeholder_empty_text() {
20447        let doc = AdfDocument {
20448            version: 1,
20449            doc_type: "doc".to_string(),
20450            content: vec![AdfNode::paragraph(vec![AdfNode::placeholder("")])],
20451        };
20452        let md = adf_to_markdown(&doc).unwrap();
20453        assert!(
20454            md.contains(":placeholder[]"),
20455            "expected empty placeholder directive, got: {md}"
20456        );
20457        let rt = markdown_to_adf(&md).unwrap();
20458        let content = rt.content[0].content.as_ref().unwrap();
20459        assert_eq!(content[0].node_type, "placeholder");
20460        assert_eq!(content[0].attrs.as_ref().unwrap()["text"], "");
20461    }
20462
20463    #[test]
20464    fn placeholder_with_surrounding_text() {
20465        let md = "Click :placeholder[here] to continue\n";
20466        let doc = markdown_to_adf(md).unwrap();
20467        let content = doc.content[0].content.as_ref().unwrap();
20468        assert_eq!(content[0].text.as_deref(), Some("Click "));
20469        assert_eq!(content[1].node_type, "placeholder");
20470        assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "here");
20471        assert_eq!(content[2].text.as_deref(), Some(" to continue"));
20472    }
20473
20474    #[test]
20475    fn placeholder_missing_attrs() {
20476        // Placeholder node with no attrs should not panic
20477        let doc = AdfDocument {
20478            version: 1,
20479            doc_type: "doc".to_string(),
20480            content: vec![AdfNode::paragraph(vec![AdfNode {
20481                node_type: "placeholder".to_string(),
20482                attrs: None,
20483                content: None,
20484                text: None,
20485                marks: None,
20486                local_id: None,
20487                parameters: None,
20488            }])],
20489        };
20490        let md = adf_to_markdown(&doc).unwrap();
20491        // With no attrs, nothing is emitted for the placeholder
20492        assert!(!md.contains("placeholder"));
20493    }
20494
20495    // Issue #446: mention in table+list loses id and misplaces localId
20496    #[test]
20497    fn mention_in_table_bullet_list_preserves_id_and_local_id() {
20498        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":" "}]}]}]}]}]}]}]}"#;
20499        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20500        let md = adf_to_markdown(&doc).unwrap();
20501        let rt = markdown_to_adf(&md).unwrap();
20502
20503        // Navigate: doc → table → tableRow → tableCell → bulletList → listItem → paragraph
20504        let cell = &rt.content[0].content.as_ref().unwrap()[0]
20505            .content
20506            .as_ref()
20507            .unwrap()[0];
20508        let list = &cell.content.as_ref().unwrap()[0];
20509        let list_item = &list.content.as_ref().unwrap()[0];
20510
20511        // listItem must NOT have a localId attribute
20512        assert!(
20513            list_item
20514                .attrs
20515                .as_ref()
20516                .and_then(|a| a.get("localId"))
20517                .is_none(),
20518            "localId should stay on the mention, not the listItem"
20519        );
20520
20521        let para = &list_item.content.as_ref().unwrap()[0];
20522        let inlines = para.content.as_ref().unwrap();
20523
20524        // Should have: text("prefix text "), mention, text(" ")
20525        assert_eq!(inlines.len(), 3, "expected 3 inline nodes, got {inlines:?}");
20526
20527        assert_eq!(inlines[0].node_type, "text");
20528        assert_eq!(inlines[0].text.as_deref(), Some("prefix text "));
20529
20530        assert_eq!(inlines[1].node_type, "mention");
20531        let mention_attrs = inlines[1].attrs.as_ref().unwrap();
20532        assert_eq!(
20533            mention_attrs["id"], "aabbccdd11223344aabbccdd",
20534            "mention id must be preserved"
20535        );
20536        assert_eq!(
20537            mention_attrs["localId"], "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
20538            "mention localId must be preserved"
20539        );
20540        assert_eq!(mention_attrs["text"], "@Alice Example");
20541
20542        assert_eq!(inlines[2].node_type, "text");
20543        assert_eq!(inlines[2].text.as_deref(), Some(" "));
20544    }
20545
20546    #[test]
20547    fn mention_in_bullet_list_preserves_id_and_local_id() {
20548        // Same bug outside of a table context
20549        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":" "}]}]}]}]}"#;
20550        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20551        let md = adf_to_markdown(&doc).unwrap();
20552        let rt = markdown_to_adf(&md).unwrap();
20553
20554        let list_item = &rt.content[0].content.as_ref().unwrap()[0];
20555        assert!(
20556            list_item
20557                .attrs
20558                .as_ref()
20559                .and_then(|a| a.get("localId"))
20560                .is_none(),
20561            "localId should stay on the mention, not the listItem"
20562        );
20563
20564        let para = &list_item.content.as_ref().unwrap()[0];
20565        let inlines = para.content.as_ref().unwrap();
20566        assert_eq!(inlines[0].node_type, "mention");
20567        let mention_attrs = inlines[0].attrs.as_ref().unwrap();
20568        assert_eq!(mention_attrs["id"], "user123");
20569        assert_eq!(
20570            mention_attrs["localId"],
20571            "11111111-2222-3333-4444-555555555555"
20572        );
20573    }
20574
20575    #[test]
20576    fn mention_in_ordered_list_preserves_id_and_local_id() {
20577        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"}}]}]}]}]}"#;
20578        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20579        let md = adf_to_markdown(&doc).unwrap();
20580        let rt = markdown_to_adf(&md).unwrap();
20581
20582        let list_item = &rt.content[0].content.as_ref().unwrap()[0];
20583        assert!(
20584            list_item
20585                .attrs
20586                .as_ref()
20587                .and_then(|a| a.get("localId"))
20588                .is_none(),
20589            "localId should stay on the mention, not the listItem"
20590        );
20591
20592        let para = &list_item.content.as_ref().unwrap()[0];
20593        let inlines = para.content.as_ref().unwrap();
20594        assert_eq!(inlines[1].node_type, "mention");
20595        let mention_attrs = inlines[1].attrs.as_ref().unwrap();
20596        assert_eq!(mention_attrs["id"], "xyz");
20597        assert_eq!(mention_attrs["localId"], "aaaa-bbbb");
20598    }
20599
20600    #[test]
20601    fn list_item_own_local_id_with_mention_both_preserved() {
20602        // When a listItem has its own localId AND contains a mention with localId,
20603        // both should be preserved independently.
20604        let md = "- hello :mention[@Eve]{id=e1 localId=mention-lid} {localId=item-lid}\n";
20605        let doc = markdown_to_adf(md).unwrap();
20606        let list_item = &doc.content[0].content.as_ref().unwrap()[0];
20607
20608        // listItem should have its own localId
20609        let item_attrs = list_item.attrs.as_ref().unwrap();
20610        assert_eq!(item_attrs["localId"], "item-lid");
20611
20612        // mention should have its own localId
20613        let para = &list_item.content.as_ref().unwrap()[0];
20614        let inlines = para.content.as_ref().unwrap();
20615        let mention = inlines.iter().find(|n| n.node_type == "mention").unwrap();
20616        let mention_attrs = mention.attrs.as_ref().unwrap();
20617        assert_eq!(mention_attrs["id"], "e1");
20618        assert_eq!(mention_attrs["localId"], "mention-lid");
20619    }
20620
20621    #[test]
20622    fn extract_trailing_local_id_ignores_directive_attrs() {
20623        // Directly test the helper: a line ending with a directive's {…}
20624        // should NOT be treated as a trailing localId.
20625        let line = "text :mention[@X]{id=abc localId=uuid}";
20626        let (text, lid, plid) = extract_trailing_local_id(line);
20627        assert_eq!(text, line, "text should be unchanged");
20628        assert!(
20629            lid.is_none(),
20630            "should not extract localId from directive attrs"
20631        );
20632        assert!(plid.is_none());
20633    }
20634
20635    #[test]
20636    fn extract_trailing_local_id_matches_standalone_block() {
20637        // A standalone trailing {localId=…} separated by whitespace should still work.
20638        let line = "some text {localId=abc-123}";
20639        let (text, lid, plid) = extract_trailing_local_id(line);
20640        assert_eq!(text, "some text");
20641        assert_eq!(lid.as_deref(), Some("abc-123"));
20642        assert!(plid.is_none());
20643    }
20644
20645    // --- Issue #454: literal newline in text node inside listItem paragraph ---
20646
20647    #[test]
20648    fn newline_in_text_node_roundtrips_in_bullet_list() {
20649        // A text node containing a literal \n inside a bullet list item
20650        // must round-trip as a single text node with the embedded newline
20651        // preserved, not split into multiple paragraphs or hardBreak nodes.
20652        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"}]}]}]}]}"#;
20653        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20654        let md = adf_to_markdown(&doc).unwrap();
20655        let rt = markdown_to_adf(&md).unwrap();
20656
20657        // Should still be a single bulletList with one listItem
20658        assert_eq!(rt.content.len(), 1);
20659        let list = &rt.content[0];
20660        assert_eq!(list.node_type, "bulletList");
20661        let items = list.content.as_ref().unwrap();
20662        assert_eq!(items.len(), 1);
20663
20664        // The listItem should have exactly one paragraph child
20665        let item_content = items[0].content.as_ref().unwrap();
20666        assert_eq!(
20667            item_content.len(),
20668            1,
20669            "listItem should have exactly one paragraph"
20670        );
20671        assert_eq!(item_content[0].node_type, "paragraph");
20672
20673        // The embedded newline must survive as a single text node
20674        let inlines = item_content[0].content.as_ref().unwrap();
20675        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20676        assert_eq!(
20677            types,
20678            vec!["text", "hardBreak", "text"],
20679            "embedded newline should stay in a single text node, not produce extra hardBreaks"
20680        );
20681        assert_eq!(
20682            inlines[2].text.as_deref(),
20683            Some("first command\nsecond command")
20684        );
20685    }
20686
20687    #[test]
20688    fn newline_in_text_node_roundtrips_in_ordered_list() {
20689        // Same as above but in an ordered list.
20690        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"}]}]}]}]}"#;
20691        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20692        let md = adf_to_markdown(&doc).unwrap();
20693        let rt = markdown_to_adf(&md).unwrap();
20694
20695        let list = &rt.content[0];
20696        assert_eq!(list.node_type, "orderedList");
20697        let items = list.content.as_ref().unwrap();
20698        assert_eq!(items.len(), 1);
20699
20700        let item_content = items[0].content.as_ref().unwrap();
20701        assert_eq!(item_content.len(), 1);
20702        assert_eq!(item_content[0].node_type, "paragraph");
20703
20704        let inlines = item_content[0].content.as_ref().unwrap();
20705        assert_eq!(inlines.len(), 1);
20706        assert_eq!(inlines[0].node_type, "text");
20707        assert_eq!(inlines[0].text.as_deref(), Some("first\nsecond"));
20708    }
20709
20710    #[test]
20711    fn newline_in_text_node_roundtrips_in_paragraph() {
20712        // A text node with \n in a top-level paragraph should render as
20713        // escaped \n and round-trip back to a single text node.
20714        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello\nworld"}]}]}"#;
20715        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20716        let md = adf_to_markdown(&doc).unwrap();
20717        assert!(
20718            md.contains("hello\\nworld"),
20719            "newline in text node should render as escaped \\n: {md:?}"
20720        );
20721
20722        let rt = markdown_to_adf(&md).unwrap();
20723        let inlines = rt.content[0].content.as_ref().unwrap();
20724        assert_eq!(inlines.len(), 1);
20725        assert_eq!(inlines[0].text.as_deref(), Some("hello\nworld"));
20726    }
20727
20728    #[test]
20729    fn multiple_newlines_in_text_node_roundtrip() {
20730        // Multiple \n characters should each round-trip within the same text node.
20731        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a\nb\nc"}]}]}]}]}"#;
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 item_content = rt.content[0].content.as_ref().unwrap()[0]
20737            .content
20738            .as_ref()
20739            .unwrap();
20740        assert_eq!(item_content.len(), 1);
20741
20742        let inlines = item_content[0].content.as_ref().unwrap();
20743        assert_eq!(inlines.len(), 1);
20744        assert_eq!(inlines[0].text.as_deref(), Some("a\nb\nc"));
20745    }
20746
20747    #[test]
20748    fn newline_in_marked_text_node_roundtrips() {
20749        // A bold text node with \n should round-trip preserving both
20750        // the marks and the embedded newline.
20751        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"bold\ntext","marks":[{"type":"strong"}]}]}]}"#;
20752        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20753        let md = adf_to_markdown(&doc).unwrap();
20754        assert!(
20755            md.contains("**bold\\ntext**"),
20756            "bold text with embedded newline should stay in one marked run: {md:?}"
20757        );
20758
20759        let rt = markdown_to_adf(&md).unwrap();
20760        let inlines = rt.content[0].content.as_ref().unwrap();
20761        assert_eq!(inlines.len(), 1);
20762        assert_eq!(inlines[0].text.as_deref(), Some("bold\ntext"));
20763        assert!(inlines[0]
20764            .marks
20765            .as_ref()
20766            .unwrap()
20767            .iter()
20768            .any(|m| m.mark_type == "strong"));
20769    }
20770
20771    #[test]
20772    fn trailing_newline_in_text_node_roundtrips() {
20773        // A text node ending with \n should round-trip preserving the
20774        // trailing newline.
20775        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"trailing\n"}]}]}"#;
20776        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20777        let md = adf_to_markdown(&doc).unwrap();
20778        assert!(
20779            md.contains("trailing\\n"),
20780            "trailing newline should be escaped: {md:?}"
20781        );
20782
20783        let rt = markdown_to_adf(&md).unwrap();
20784        let inlines = rt.content[0].content.as_ref().unwrap();
20785        assert_eq!(inlines.len(), 1);
20786        assert_eq!(inlines[0].text.as_deref(), Some("trailing\n"));
20787    }
20788
20789    #[test]
20790    fn hardbreak_and_embedded_newline_are_distinct() {
20791        // A hardBreak node and an embedded \n in a text node must not be
20792        // conflated — each must round-trip to its original form.
20793        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"}]}]}"#;
20794        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20795        let md = adf_to_markdown(&doc).unwrap();
20796        let rt = markdown_to_adf(&md).unwrap();
20797
20798        let inlines = rt.content[0].content.as_ref().unwrap();
20799        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20800        assert_eq!(
20801            types,
20802            vec!["text", "hardBreak", "text", "hardBreak", "text"]
20803        );
20804        assert_eq!(inlines[0].text.as_deref(), Some("before"));
20805        assert_eq!(inlines[2].text.as_deref(), Some("mid\ndle"));
20806        assert_eq!(inlines[4].text.as_deref(), Some("after"));
20807    }
20808
20809    // ---- Issue #472 tests ----
20810
20811    #[test]
20812    fn issue_472_bullet_list_trailing_hardbreak_roundtrips() {
20813        // Issue #472: trailing hardBreak at end of listItem paragraph must
20814        // not split the parent bulletList on round-trip.
20815        let adf_json = r#"{"version":1,"type":"doc","content":[
20816          {"type":"bulletList","content":[
20817            {"type":"listItem","content":[
20818              {"type":"paragraph","content":[
20819                {"type":"text","text":"First item"},
20820                {"type":"hardBreak"}
20821              ]}
20822            ]},
20823            {"type":"listItem","content":[
20824              {"type":"paragraph","content":[
20825                {"type":"text","text":"Second item"}
20826              ]}
20827            ]}
20828          ]}
20829        ]}"#;
20830        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20831        let md = adf_to_markdown(&doc).unwrap();
20832        let rt = markdown_to_adf(&md).unwrap();
20833
20834        // Must remain a single bulletList
20835        assert_eq!(
20836            rt.content.len(),
20837            1,
20838            "Should be 1 block (bulletList), got {}",
20839            rt.content.len()
20840        );
20841        assert_eq!(rt.content[0].node_type, "bulletList");
20842        let items = rt.content[0].content.as_ref().unwrap();
20843        assert_eq!(
20844            items.len(),
20845            2,
20846            "Should have 2 listItems, got {}",
20847            items.len()
20848        );
20849
20850        // First item: text + hardBreak (trailing)
20851        let p1 = items[0].content.as_ref().unwrap()[0]
20852            .content
20853            .as_ref()
20854            .unwrap();
20855        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
20856        assert_eq!(types1, vec!["text", "hardBreak"]);
20857        assert_eq!(p1[0].text.as_deref(), Some("First item"));
20858
20859        // Second item: text only
20860        let p2 = items[1].content.as_ref().unwrap()[0]
20861            .content
20862            .as_ref()
20863            .unwrap();
20864        assert_eq!(p2[0].text.as_deref(), Some("Second item"));
20865    }
20866
20867    #[test]
20868    fn issue_472_ordered_list_trailing_hardbreak_roundtrips() {
20869        // Ordered list variant of issue #472.
20870        let adf_json = r#"{"version":1,"type":"doc","content":[
20871          {"type":"orderedList","attrs":{"order":1},"content":[
20872            {"type":"listItem","content":[
20873              {"type":"paragraph","content":[
20874                {"type":"text","text":"Alpha"},
20875                {"type":"hardBreak"}
20876              ]}
20877            ]},
20878            {"type":"listItem","content":[
20879              {"type":"paragraph","content":[
20880                {"type":"text","text":"Beta"}
20881              ]}
20882            ]}
20883          ]}
20884        ]}"#;
20885        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20886        let md = adf_to_markdown(&doc).unwrap();
20887        let rt = markdown_to_adf(&md).unwrap();
20888
20889        assert_eq!(rt.content.len(), 1);
20890        assert_eq!(rt.content[0].node_type, "orderedList");
20891        let items = rt.content[0].content.as_ref().unwrap();
20892        assert_eq!(items.len(), 2);
20893
20894        let p1 = items[0].content.as_ref().unwrap()[0]
20895            .content
20896            .as_ref()
20897            .unwrap();
20898        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
20899        assert_eq!(types1, vec!["text", "hardBreak"]);
20900        assert_eq!(p1[0].text.as_deref(), Some("Alpha"));
20901    }
20902
20903    #[test]
20904    fn issue_472_trailing_hardbreak_jfm_no_blank_line() {
20905        // The rendered JFM must not contain a blank line after the
20906        // trailing hardBreak — that would split the list.
20907        let adf_json = r#"{"version":1,"type":"doc","content":[
20908          {"type":"bulletList","content":[
20909            {"type":"listItem","content":[
20910              {"type":"paragraph","content":[
20911                {"type":"text","text":"Hello"},
20912                {"type":"hardBreak"}
20913              ]}
20914            ]},
20915            {"type":"listItem","content":[
20916              {"type":"paragraph","content":[
20917                {"type":"text","text":"World"}
20918              ]}
20919            ]}
20920          ]}
20921        ]}"#;
20922        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20923        let md = adf_to_markdown(&doc).unwrap();
20924
20925        // Should produce "- Hello\\n- World\n" (no blank line between items).
20926        assert_eq!(md, "- Hello\\\n- World\n");
20927    }
20928
20929    #[test]
20930    fn issue_472_multiple_trailing_hardbreaks_roundtrip() {
20931        // Multiple trailing hardBreaks at the end of a listItem paragraph.
20932        let adf_json = r#"{"version":1,"type":"doc","content":[
20933          {"type":"bulletList","content":[
20934            {"type":"listItem","content":[
20935              {"type":"paragraph","content":[
20936                {"type":"text","text":"Item"},
20937                {"type":"hardBreak"},
20938                {"type":"hardBreak"}
20939              ]}
20940            ]},
20941            {"type":"listItem","content":[
20942              {"type":"paragraph","content":[
20943                {"type":"text","text":"Next"}
20944              ]}
20945            ]}
20946          ]}
20947        ]}"#;
20948        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20949        let md = adf_to_markdown(&doc).unwrap();
20950        let rt = markdown_to_adf(&md).unwrap();
20951
20952        // Must remain a single bulletList
20953        assert_eq!(rt.content.len(), 1);
20954        assert_eq!(rt.content[0].node_type, "bulletList");
20955        let items = rt.content[0].content.as_ref().unwrap();
20956        assert_eq!(items.len(), 2);
20957
20958        // First item should preserve both hardBreaks
20959        let p1 = items[0].content.as_ref().unwrap()[0]
20960            .content
20961            .as_ref()
20962            .unwrap();
20963        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
20964        assert_eq!(types1, vec!["text", "hardBreak", "hardBreak"]);
20965    }
20966
20967    #[test]
20968    fn issue_472_hardbreak_mid_and_trailing_roundtrip() {
20969        // A hardBreak in the middle AND at the end of a listItem paragraph.
20970        let adf_json = r#"{"version":1,"type":"doc","content":[
20971          {"type":"bulletList","content":[
20972            {"type":"listItem","content":[
20973              {"type":"paragraph","content":[
20974                {"type":"text","text":"Line one"},
20975                {"type":"hardBreak"},
20976                {"type":"text","text":"Line two"},
20977                {"type":"hardBreak"}
20978              ]}
20979            ]},
20980            {"type":"listItem","content":[
20981              {"type":"paragraph","content":[
20982                {"type":"text","text":"Other item"}
20983              ]}
20984            ]}
20985          ]}
20986        ]}"#;
20987        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20988        let md = adf_to_markdown(&doc).unwrap();
20989        let rt = markdown_to_adf(&md).unwrap();
20990
20991        assert_eq!(rt.content.len(), 1);
20992        assert_eq!(rt.content[0].node_type, "bulletList");
20993        let items = rt.content[0].content.as_ref().unwrap();
20994        assert_eq!(items.len(), 2);
20995
20996        let p1 = items[0].content.as_ref().unwrap()[0]
20997            .content
20998            .as_ref()
20999            .unwrap();
21000        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
21001        assert_eq!(types1, vec!["text", "hardBreak", "text", "hardBreak"]);
21002        assert_eq!(p1[0].text.as_deref(), Some("Line one"));
21003        assert_eq!(p1[2].text.as_deref(), Some("Line two"));
21004    }
21005
21006    #[test]
21007    fn issue_472_only_hardbreak_in_listitem_paragraph() {
21008        // Edge case: paragraph contains only a hardBreak, no text.
21009        let adf_json = r#"{"version":1,"type":"doc","content":[
21010          {"type":"bulletList","content":[
21011            {"type":"listItem","content":[
21012              {"type":"paragraph","content":[
21013                {"type":"hardBreak"}
21014              ]}
21015            ]},
21016            {"type":"listItem","content":[
21017              {"type":"paragraph","content":[
21018                {"type":"text","text":"After"}
21019              ]}
21020            ]}
21021          ]}
21022        ]}"#;
21023        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21024        let md = adf_to_markdown(&doc).unwrap();
21025        let rt = markdown_to_adf(&md).unwrap();
21026
21027        // Must remain a single bulletList with 2 items
21028        assert_eq!(rt.content.len(), 1);
21029        assert_eq!(rt.content[0].node_type, "bulletList");
21030        let items = rt.content[0].content.as_ref().unwrap();
21031        assert_eq!(items.len(), 2);
21032    }
21033
21034    #[test]
21035    fn issue_472_three_items_middle_has_trailing_hardbreak() {
21036        // Three-item list where only the middle item has a trailing hardBreak.
21037        let adf_json = r#"{"version":1,"type":"doc","content":[
21038          {"type":"bulletList","content":[
21039            {"type":"listItem","content":[
21040              {"type":"paragraph","content":[
21041                {"type":"text","text":"First"}
21042              ]}
21043            ]},
21044            {"type":"listItem","content":[
21045              {"type":"paragraph","content":[
21046                {"type":"text","text":"Second"},
21047                {"type":"hardBreak"}
21048              ]}
21049            ]},
21050            {"type":"listItem","content":[
21051              {"type":"paragraph","content":[
21052                {"type":"text","text":"Third"}
21053              ]}
21054            ]}
21055          ]}
21056        ]}"#;
21057        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21058        let md = adf_to_markdown(&doc).unwrap();
21059        let rt = markdown_to_adf(&md).unwrap();
21060
21061        assert_eq!(rt.content.len(), 1);
21062        assert_eq!(rt.content[0].node_type, "bulletList");
21063        let items = rt.content[0].content.as_ref().unwrap();
21064        assert_eq!(items.len(), 3);
21065        assert_eq!(
21066            items[0].content.as_ref().unwrap()[0]
21067                .content
21068                .as_ref()
21069                .unwrap()[0]
21070                .text
21071                .as_deref(),
21072            Some("First")
21073        );
21074        assert_eq!(
21075            items[2].content.as_ref().unwrap()[0]
21076                .content
21077                .as_ref()
21078                .unwrap()[0]
21079                .text
21080                .as_deref(),
21081            Some("Third")
21082        );
21083    }
21084
21085    // ── Issue #494: trailing space-only text node after hardBreak ────
21086
21087    #[test]
21088    fn issue_494_space_after_hardbreak_roundtrip() {
21089        // The original reproducer from issue #494: a single space text
21090        // node following a hardBreak is silently dropped on round-trip.
21091        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21092          {"type":"text","text":"Some text"},
21093          {"type":"hardBreak"},
21094          {"type":"text","text":" "}
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"],
21104            "space-only text node after hardBreak should survive round-trip"
21105        );
21106        assert_eq!(inlines[2].text.as_deref(), Some(" "));
21107    }
21108
21109    #[test]
21110    fn issue_494_multiple_spaces_after_hardbreak_roundtrip() {
21111        // Multiple spaces after hardBreak should also survive.
21112        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21113          {"type":"text","text":"Hello"},
21114          {"type":"hardBreak"},
21115          {"type":"text","text":"   "}
21116        ]}]}"#;
21117        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21118        let md = adf_to_markdown(&doc).unwrap();
21119        let rt = markdown_to_adf(&md).unwrap();
21120        let inlines = rt.content[0].content.as_ref().unwrap();
21121        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21122        assert_eq!(
21123            types,
21124            vec!["text", "hardBreak", "text"],
21125            "multi-space text node after hardBreak should survive round-trip"
21126        );
21127        assert_eq!(inlines[2].text.as_deref(), Some("   "));
21128    }
21129
21130    #[test]
21131    fn issue_494_space_then_text_after_hardbreak_roundtrip() {
21132        // Space followed by real text after hardBreak — the space should
21133        // be preserved as part of the text node.
21134        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21135          {"type":"text","text":"Before"},
21136          {"type":"hardBreak"},
21137          {"type":"text","text":" After"}
21138        ]}]}"#;
21139        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21140        let md = adf_to_markdown(&doc).unwrap();
21141        let rt = markdown_to_adf(&md).unwrap();
21142        let inlines = rt.content[0].content.as_ref().unwrap();
21143        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21144        assert_eq!(types, vec!["text", "hardBreak", "text"]);
21145        assert_eq!(inlines[2].text.as_deref(), Some(" After"));
21146    }
21147
21148    #[test]
21149    fn issue_494_hardbreak_then_space_then_hardbreak_roundtrip() {
21150        // Space sandwiched between two hardBreaks.
21151        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21152          {"type":"text","text":"A"},
21153          {"type":"hardBreak"},
21154          {"type":"text","text":" "},
21155          {"type":"hardBreak"},
21156          {"type":"text","text":"B"}
21157        ]}]}"#;
21158        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21159        let md = adf_to_markdown(&doc).unwrap();
21160        let rt = markdown_to_adf(&md).unwrap();
21161        let inlines = rt.content[0].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", "hardBreak", "text"],
21166            "space between two hardBreaks should survive round-trip"
21167        );
21168        assert_eq!(inlines[2].text.as_deref(), Some(" "));
21169        assert_eq!(inlines[4].text.as_deref(), Some("B"));
21170    }
21171
21172    #[test]
21173    fn issue_494_trailing_space_hardbreak_style_not_confused() {
21174        // A plain paragraph break (blank line) should still work after
21175        // a line that does NOT end with a hardBreak marker.
21176        let md = "first paragraph\n\nsecond paragraph\n";
21177        let doc = markdown_to_adf(md).unwrap();
21178        assert_eq!(
21179            doc.content.len(),
21180            2,
21181            "blank line should still separate paragraphs"
21182        );
21183    }
21184
21185    #[test]
21186    fn issue_494_space_after_trailing_space_hardbreak_roundtrip() {
21187        // Same bug but with trailing-space style hardBreak (two spaces
21188        // before newline) instead of backslash style.
21189        let md = "line one  \n   \n";
21190        // The above is: "line one" + trailing-space hardBreak + continuation
21191        // line "   " (2-space indent + 1 space content).  The space-only
21192        // continuation should not be treated as a blank paragraph break.
21193        let doc = markdown_to_adf(md).unwrap();
21194        let inlines = doc.content[0].content.as_ref().unwrap();
21195        let has_text_after_break = inlines.iter().any(|n| {
21196            n.node_type == "text"
21197                && n.text
21198                    .as_deref()
21199                    .is_some_and(|t| t.trim().is_empty() && !t.is_empty())
21200        });
21201        assert!(
21202            has_text_after_break || inlines.len() >= 2,
21203            "space-only line after trailing-space hardBreak should be preserved"
21204        );
21205    }
21206
21207    #[test]
21208    fn issue_494_space_after_hardbreak_in_list_item_roundtrip() {
21209        // Exercises the same bug inside a list item context.
21210        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
21211          {"type":"listItem","content":[{"type":"paragraph","content":[
21212            {"type":"text","text":"item"},
21213            {"type":"hardBreak"},
21214            {"type":"text","text":" "}
21215          ]}]}
21216        ]}]}"#;
21217        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21218        let md = adf_to_markdown(&doc).unwrap();
21219        let rt = markdown_to_adf(&md).unwrap();
21220        let list = &rt.content[0];
21221        let item = &list.content.as_ref().unwrap()[0];
21222        let para = &item.content.as_ref().unwrap()[0];
21223        let inlines = para.content.as_ref().unwrap();
21224        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21225        assert_eq!(
21226            types,
21227            vec!["text", "hardBreak", "text"],
21228            "space after hardBreak in list item should survive round-trip"
21229        );
21230        assert_eq!(inlines[2].text.as_deref(), Some(" "));
21231    }
21232
21233    // ── Issue #510: trailing spaces in text node should not become hardBreak ──
21234
21235    #[test]
21236    fn issue_510_trailing_double_space_paragraph_roundtrip() {
21237        // Two trailing spaces in a text node must survive round-trip without
21238        // being converted to a hardBreak or merging the next paragraph.
21239        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"}]}]}"#;
21240        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21241        let md = adf_to_markdown(&doc).unwrap();
21242        let rt = markdown_to_adf(&md).unwrap();
21243
21244        // Must produce two separate paragraphs
21245        assert_eq!(
21246            rt.content.len(),
21247            2,
21248            "should produce two paragraphs, got: {}",
21249            rt.content.len()
21250        );
21251        assert_eq!(rt.content[0].node_type, "paragraph");
21252        assert_eq!(rt.content[1].node_type, "paragraph");
21253
21254        // First paragraph text preserves trailing spaces
21255        let p1 = rt.content[0].content.as_ref().unwrap();
21256        assert_eq!(
21257            p1[0].text.as_deref(),
21258            Some("first paragraph with trailing spaces  "),
21259            "trailing spaces should be preserved in first paragraph"
21260        );
21261
21262        // Second paragraph is intact
21263        let p2 = rt.content[1].content.as_ref().unwrap();
21264        assert_eq!(p2[0].text.as_deref(), Some("second paragraph"));
21265
21266        // No hardBreak nodes should exist
21267        let all_types: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
21268        assert!(
21269            !all_types.contains(&"hardBreak"),
21270            "trailing spaces should not produce hardBreak, got: {all_types:?}"
21271        );
21272    }
21273
21274    #[test]
21275    fn issue_510_trailing_triple_space_roundtrip() {
21276        // Three trailing spaces also must not become a hardBreak.
21277        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"text   "}]},{"type":"paragraph","content":[{"type":"text","text":"next"}]}]}"#;
21278        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21279        let md = adf_to_markdown(&doc).unwrap();
21280        let rt = markdown_to_adf(&md).unwrap();
21281
21282        assert_eq!(rt.content.len(), 2, "should still be two paragraphs");
21283        let p1 = rt.content[0].content.as_ref().unwrap();
21284        assert_eq!(
21285            p1[0].text.as_deref(),
21286            Some("text   "),
21287            "three trailing spaces should be preserved"
21288        );
21289    }
21290
21291    #[test]
21292    fn issue_510_trailing_spaces_with_backslash_roundtrip() {
21293        // Text ending with backslash + trailing spaces: both must survive.
21294        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"end\\  "}]}]}"#;
21295        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21296        let md = adf_to_markdown(&doc).unwrap();
21297        let rt = markdown_to_adf(&md).unwrap();
21298        let p = rt.content[0].content.as_ref().unwrap();
21299        assert_eq!(
21300            p[0].text.as_deref(),
21301            Some("end\\  "),
21302            "backslash + trailing spaces should both survive"
21303        );
21304    }
21305
21306    #[test]
21307    fn issue_510_jfm_contains_escaped_trailing_space() {
21308        // Verify the serializer actually emits the backslash-space escape.
21309        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello  "}]}]}"#;
21310        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21311        let md = adf_to_markdown(&doc).unwrap();
21312        assert!(
21313            md.contains(r"\ "),
21314            "JFM should contain backslash-space escape for trailing spaces, got: {md:?}"
21315        );
21316        // Must NOT end with two plain spaces before newline
21317        for line in md.lines() {
21318            assert!(
21319                !line.ends_with("  "),
21320                "no JFM line should end with two plain spaces, got: {line:?}"
21321            );
21322        }
21323    }
21324
21325    #[test]
21326    fn issue_510_single_trailing_space_not_escaped() {
21327        // A single trailing space should NOT be escaped (not a hardBreak trigger).
21328        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"word "}]}]}"#;
21329        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21330        let md = adf_to_markdown(&doc).unwrap();
21331        assert!(
21332            !md.contains('\\'),
21333            "single trailing space should not be escaped, got: {md:?}"
21334        );
21335        let rt = markdown_to_adf(&md).unwrap();
21336        let p = rt.content[0].content.as_ref().unwrap();
21337        assert_eq!(p[0].text.as_deref(), Some("word "));
21338    }
21339
21340    #[test]
21341    fn issue_510_trailing_spaces_in_heading_roundtrip() {
21342        // Trailing double-spaces in a heading text node should also survive.
21343        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"heading  "}]}]}"#;
21344        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21345        let md = adf_to_markdown(&doc).unwrap();
21346        let rt = markdown_to_adf(&md).unwrap();
21347        let h = rt.content[0].content.as_ref().unwrap();
21348        assert_eq!(
21349            h[0].text.as_deref(),
21350            Some("heading  "),
21351            "trailing spaces in heading should be preserved"
21352        );
21353    }
21354
21355    #[test]
21356    fn issue_510_trailing_spaces_in_list_item_roundtrip() {
21357        // Trailing double-spaces in a bullet list item text node.
21358        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"item  "}]}]}]}]}"#;
21359        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21360        let md = adf_to_markdown(&doc).unwrap();
21361        let rt = markdown_to_adf(&md).unwrap();
21362        let list = &rt.content[0];
21363        let item = &list.content.as_ref().unwrap()[0];
21364        let para = &item.content.as_ref().unwrap()[0];
21365        let inlines = para.content.as_ref().unwrap();
21366        assert_eq!(
21367            inlines[0].text.as_deref(),
21368            Some("item  "),
21369            "trailing spaces in list item should be preserved"
21370        );
21371    }
21372
21373    #[test]
21374    fn issue_510_trailing_spaces_with_bold_mark_roundtrip() {
21375        // Trailing spaces in a bold-marked text node: the closing **
21376        // comes after the spaces, so the line doesn't end with spaces.
21377        // But the escape should still be applied (and be harmless).
21378        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"bold  ","marks":[{"type":"strong"}]}]}]}"#;
21379        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21380        let md = adf_to_markdown(&doc).unwrap();
21381        let rt = markdown_to_adf(&md).unwrap();
21382        let p = rt.content[0].content.as_ref().unwrap();
21383        assert_eq!(
21384            p[0].text.as_deref(),
21385            Some("bold  "),
21386            "trailing spaces in bold text should be preserved"
21387        );
21388    }
21389
21390    #[test]
21391    fn issue_510_hardbreak_between_paragraphs_still_works() {
21392        // Actual hardBreak nodes must still round-trip correctly.
21393        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"line one"},{"type":"hardBreak"},{"type":"text","text":"line two"}]}]}"#;
21394        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21395        let md = adf_to_markdown(&doc).unwrap();
21396        let rt = markdown_to_adf(&md).unwrap();
21397        let inlines = rt.content[0].content.as_ref().unwrap();
21398        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21399        assert_eq!(
21400            types,
21401            vec!["text", "hardBreak", "text"],
21402            "explicit hardBreak should still round-trip"
21403        );
21404    }
21405
21406    #[test]
21407    fn issue_510_all_spaces_text_node_roundtrip() {
21408        // A text node that is entirely spaces (2+) should survive.
21409        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"  "}]}]}"#;
21410        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21411        let md = adf_to_markdown(&doc).unwrap();
21412        let rt = markdown_to_adf(&md).unwrap();
21413        let p = rt.content[0].content.as_ref().unwrap();
21414        assert_eq!(
21415            p[0].text.as_deref(),
21416            Some("  "),
21417            "space-only text node should survive round-trip"
21418        );
21419    }
21420
21421    // ── Issue #522: listItem multi-paragraph merge ──────────────────────
21422
21423    #[test]
21424    fn issue_522_listitem_hardbreak_then_two_paragraphs_roundtrips() {
21425        // The exact reproducer from issue #522: first paragraph has
21426        // hardBreak nodes, followed by two sibling paragraphs.
21427        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"}]}]}]}]}"#;
21428        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21429        let md = adf_to_markdown(&doc).unwrap();
21430        let rt = markdown_to_adf(&md).unwrap();
21431
21432        let items = rt.content[0].content.as_ref().unwrap();
21433        assert_eq!(items.len(), 1);
21434        let children = items[0].content.as_ref().unwrap();
21435        assert_eq!(
21436            children.len(),
21437            3,
21438            "Expected 3 paragraphs in listItem, got {}",
21439            children.len()
21440        );
21441        assert_eq!(children[0].node_type, "paragraph");
21442        assert_eq!(children[1].node_type, "paragraph");
21443        assert_eq!(children[2].node_type, "paragraph");
21444
21445        // Verify the text content of each paragraph
21446        let text1 = children[1].content.as_ref().unwrap()[0]
21447            .text
21448            .as_deref()
21449            .unwrap();
21450        assert_eq!(text1, "second paragraph");
21451        let text2 = children[2].content.as_ref().unwrap()[0]
21452            .text
21453            .as_deref()
21454            .unwrap();
21455        assert_eq!(text2, "third paragraph");
21456    }
21457
21458    #[test]
21459    fn issue_522_ordered_list_hardbreak_then_paragraphs_roundtrips() {
21460        // Same scenario in an ordered list.
21461        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"}]}]}]}]}"#;
21462        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21463        let md = adf_to_markdown(&doc).unwrap();
21464        let rt = markdown_to_adf(&md).unwrap();
21465
21466        let items = rt.content[0].content.as_ref().unwrap();
21467        let children = items[0].content.as_ref().unwrap();
21468        assert_eq!(
21469            children.len(),
21470            3,
21471            "Expected 3 paragraphs in ordered listItem, got {}",
21472            children.len()
21473        );
21474        assert_eq!(children[1].node_type, "paragraph");
21475        assert_eq!(children[2].node_type, "paragraph");
21476        assert_eq!(
21477            children[1].content.as_ref().unwrap()[0]
21478                .text
21479                .as_deref()
21480                .unwrap(),
21481            "second para"
21482        );
21483        assert_eq!(
21484            children[2].content.as_ref().unwrap()[0]
21485                .text
21486                .as_deref()
21487                .unwrap(),
21488            "third para"
21489        );
21490    }
21491
21492    #[test]
21493    fn issue_522_two_paragraphs_without_hardbreak_roundtrips() {
21494        // Two paragraphs without hardBreak — should also remain separate.
21495        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"}]}]}]}]}"#;
21496        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21497        let md = adf_to_markdown(&doc).unwrap();
21498        let rt = markdown_to_adf(&md).unwrap();
21499
21500        let items = rt.content[0].content.as_ref().unwrap();
21501        let children = items[0].content.as_ref().unwrap();
21502        assert_eq!(
21503            children.len(),
21504            2,
21505            "Expected 2 paragraphs in listItem, got {}",
21506            children.len()
21507        );
21508        assert_eq!(children[0].node_type, "paragraph");
21509        assert_eq!(children[1].node_type, "paragraph");
21510    }
21511
21512    #[test]
21513    fn issue_522_paragraph_then_nested_list_no_spurious_blank() {
21514        // A paragraph followed by a nested list should NOT get a blank
21515        // separator (only paragraph-paragraph transitions need one).
21516        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"}]}]}]}]}]}]}"#;
21517        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21518        let md = adf_to_markdown(&doc).unwrap();
21519        // Should not contain a blank indented line between parent text and sub-list
21520        assert!(
21521            !md.contains("  \n  -"),
21522            "No blank separator between paragraph and nested list"
21523        );
21524        let rt = markdown_to_adf(&md).unwrap();
21525
21526        let items = rt.content[0].content.as_ref().unwrap();
21527        let children = items[0].content.as_ref().unwrap();
21528        assert_eq!(children.len(), 2);
21529        assert_eq!(children[0].node_type, "paragraph");
21530        assert_eq!(children[1].node_type, "bulletList");
21531    }
21532
21533    #[test]
21534    fn issue_522_three_paragraphs_no_hardbreak_roundtrips() {
21535        // Three plain paragraphs (no hardBreak) inside a single listItem.
21536        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"}]}]}]}]}"#;
21537        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21538        let md = adf_to_markdown(&doc).unwrap();
21539        let rt = markdown_to_adf(&md).unwrap();
21540
21541        let items = rt.content[0].content.as_ref().unwrap();
21542        let children = items[0].content.as_ref().unwrap();
21543        assert_eq!(
21544            children.len(),
21545            3,
21546            "Expected 3 paragraphs, got {}",
21547            children.len()
21548        );
21549        for (i, child) in children.iter().enumerate() {
21550            assert_eq!(
21551                child.node_type, "paragraph",
21552                "Child {i} should be a paragraph"
21553            );
21554        }
21555    }
21556
21557    #[test]
21558    fn issue_522_multiple_list_items_each_with_paragraphs() {
21559        // Multiple list items, each with multiple paragraphs.
21560        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"}]}]}]}]}"#;
21561        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21562        let md = adf_to_markdown(&doc).unwrap();
21563        let rt = markdown_to_adf(&md).unwrap();
21564
21565        let items = rt.content[0].content.as_ref().unwrap();
21566        assert_eq!(items.len(), 2, "Expected 2 list items");
21567
21568        let item1 = items[0].content.as_ref().unwrap();
21569        assert_eq!(item1.len(), 2, "Item 1 should have 2 paragraphs");
21570
21571        let item2 = items[1].content.as_ref().unwrap();
21572        assert_eq!(item2.len(), 2, "Item 2 should have 2 paragraphs");
21573        // Verify hardBreak is preserved in item2's first paragraph
21574        let item2_p1_inlines = item2[0].content.as_ref().unwrap();
21575        let types: Vec<&str> = item2_p1_inlines
21576            .iter()
21577            .map(|n| n.node_type.as_str())
21578            .collect();
21579        assert_eq!(types, vec!["text", "hardBreak", "text"]);
21580    }
21581
21582    #[test]
21583    fn issue_531_blockquote_hardbreak_then_two_paragraphs_roundtrips() {
21584        // The exact reproducer from issue #531: blockquote with first
21585        // paragraph containing hardBreak nodes, followed by two sibling
21586        // paragraphs.
21587        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"}]}]}]}"#;
21588        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21589        let md = adf_to_markdown(&doc).unwrap();
21590        let rt = markdown_to_adf(&md).unwrap();
21591
21592        let children = rt.content[0].content.as_ref().unwrap();
21593        assert_eq!(
21594            children.len(),
21595            3,
21596            "Expected 3 paragraphs in blockquote, got {}",
21597            children.len()
21598        );
21599        assert_eq!(children[0].node_type, "paragraph");
21600        assert_eq!(children[1].node_type, "paragraph");
21601        assert_eq!(children[2].node_type, "paragraph");
21602
21603        let text1 = children[1].content.as_ref().unwrap()[0]
21604            .text
21605            .as_deref()
21606            .unwrap();
21607        assert_eq!(text1, "second paragraph");
21608        let text2 = children[2].content.as_ref().unwrap()[0]
21609            .text
21610            .as_deref()
21611            .unwrap();
21612        assert_eq!(text2, "third paragraph");
21613    }
21614
21615    #[test]
21616    fn issue_531_blockquote_two_paragraphs_without_hardbreak_roundtrips() {
21617        // Two simple paragraphs inside a blockquote, no hardBreak.
21618        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"}]}]}]}"#;
21619        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21620        let md = adf_to_markdown(&doc).unwrap();
21621        let rt = markdown_to_adf(&md).unwrap();
21622
21623        let children = rt.content[0].content.as_ref().unwrap();
21624        assert_eq!(
21625            children.len(),
21626            2,
21627            "Expected 2 paragraphs in blockquote, got {}",
21628            children.len()
21629        );
21630        assert_eq!(children[0].node_type, "paragraph");
21631        assert_eq!(children[1].node_type, "paragraph");
21632    }
21633
21634    #[test]
21635    fn issue_531_blockquote_three_paragraphs_no_hardbreak_roundtrips() {
21636        // Three paragraphs, none with hardBreak.
21637        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"}]}]}]}"#;
21638        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21639        let md = adf_to_markdown(&doc).unwrap();
21640        let rt = markdown_to_adf(&md).unwrap();
21641
21642        let children = rt.content[0].content.as_ref().unwrap();
21643        assert_eq!(
21644            children.len(),
21645            3,
21646            "Expected 3 paragraphs in blockquote, got {}",
21647            children.len()
21648        );
21649        for child in children {
21650            assert_eq!(child.node_type, "paragraph");
21651        }
21652    }
21653
21654    #[test]
21655    fn issue_531_blockquote_paragraph_then_list_no_spurious_blank() {
21656        // A paragraph followed by a nested list inside a blockquote —
21657        // should NOT insert a blank separator line.
21658        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"}]}]}]}]}]}"#;
21659        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21660        let md = adf_to_markdown(&doc).unwrap();
21661        let rt = markdown_to_adf(&md).unwrap();
21662
21663        let children = rt.content[0].content.as_ref().unwrap();
21664        assert_eq!(children[0].node_type, "paragraph");
21665        assert_eq!(children[1].node_type, "bulletList");
21666    }
21667
21668    #[test]
21669    fn issue_531_blockquote_single_paragraph_unchanged() {
21670        // A single paragraph in a blockquote should remain unchanged.
21671        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"solo"}]}]}]}"#;
21672        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21673        let md = adf_to_markdown(&doc).unwrap();
21674        let rt = markdown_to_adf(&md).unwrap();
21675
21676        let children = rt.content[0].content.as_ref().unwrap();
21677        assert_eq!(children.len(), 1);
21678        assert_eq!(children[0].node_type, "paragraph");
21679        let text = children[0].content.as_ref().unwrap()[0]
21680            .text
21681            .as_deref()
21682            .unwrap();
21683        assert_eq!(text, "solo");
21684    }
21685
21686    // ── Issue #554: marks combined with `code` or with each other ──────
21687
21688    /// Helper: roundtrip an ADF document and assert the marks on the first
21689    /// text node match `expected_marks` (in order).
21690    fn assert_roundtrip_marks(adf_json: &str, expected_marks: &[&str]) {
21691        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21692        let md = adf_to_markdown(&doc).unwrap();
21693        let rt = markdown_to_adf(&md).unwrap();
21694        let node = &rt.content[0].content.as_ref().unwrap()[0];
21695        let mark_types: Vec<&str> = node
21696            .marks
21697            .as_ref()
21698            .expect("should have marks")
21699            .iter()
21700            .map(|m| m.mark_type.as_str())
21701            .collect();
21702        assert_eq!(
21703            mark_types, expected_marks,
21704            "mark order mismatch for md={md}"
21705        );
21706    }
21707
21708    #[test]
21709    fn issue_554_code_and_text_color_preserved() {
21710        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21711          {"type":"text","text":"x","marks":[
21712            {"type":"textColor","attrs":{"color":"#008000"}},
21713            {"type":"code"}
21714          ]}
21715        ]}]}"##;
21716        assert_roundtrip_marks(adf_json, &["textColor", "code"]);
21717    }
21718
21719    #[test]
21720    fn issue_554_code_and_bg_color_preserved() {
21721        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21722          {"type":"text","text":"x","marks":[
21723            {"type":"backgroundColor","attrs":{"color":"#FF0000"}},
21724            {"type":"code"}
21725          ]}
21726        ]}]}"##;
21727        assert_roundtrip_marks(adf_json, &["backgroundColor", "code"]);
21728    }
21729
21730    #[test]
21731    fn issue_554_code_and_subsup_preserved() {
21732        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21733          {"type":"text","text":"x","marks":[
21734            {"type":"subsup","attrs":{"type":"sub"}},
21735            {"type":"code"}
21736          ]}
21737        ]}]}"#;
21738        assert_roundtrip_marks(adf_json, &["subsup", "code"]);
21739    }
21740
21741    #[test]
21742    fn issue_554_code_and_underline_preserved() {
21743        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21744          {"type":"text","text":"x","marks":[
21745            {"type":"underline"},
21746            {"type":"code"}
21747          ]}
21748        ]}]}"#;
21749        assert_roundtrip_marks(adf_json, &["underline", "code"]);
21750    }
21751
21752    #[test]
21753    fn issue_554_code_textcolor_and_underline_preserved() {
21754        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21755          {"type":"text","text":"x","marks":[
21756            {"type":"textColor","attrs":{"color":"#008000"}},
21757            {"type":"underline"},
21758            {"type":"code"}
21759          ]}
21760        ]}]}"##;
21761        assert_roundtrip_marks(adf_json, &["textColor", "underline", "code"]);
21762    }
21763
21764    #[test]
21765    fn issue_554_textcolor_and_underline_preserved() {
21766        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21767          {"type":"text","text":"x","marks":[
21768            {"type":"textColor","attrs":{"color":"#008000"}},
21769            {"type":"underline"}
21770          ]}
21771        ]}]}"##;
21772        assert_roundtrip_marks(adf_json, &["textColor", "underline"]);
21773    }
21774
21775    #[test]
21776    fn issue_554_underline_and_textcolor_preserved_order_swapped() {
21777        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21778          {"type":"text","text":"x","marks":[
21779            {"type":"underline"},
21780            {"type":"textColor","attrs":{"color":"#008000"}}
21781          ]}
21782        ]}]}"##;
21783        // underline appears first, so it should be the OUTER wrapper.
21784        assert_roundtrip_marks(adf_json, &["underline", "textColor"]);
21785    }
21786
21787    #[test]
21788    fn issue_554_textcolor_and_annotation_preserved() {
21789        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21790          {"type":"text","text":"x","marks":[
21791            {"type":"textColor","attrs":{"color":"#008000"}},
21792            {"type":"annotation","attrs":{"id":"abc-123","annotationType":"inlineComment"}}
21793          ]}
21794        ]}]}"##;
21795        assert_roundtrip_marks(adf_json, &["textColor", "annotation"]);
21796    }
21797
21798    #[test]
21799    fn issue_554_bgcolor_and_underline_preserved() {
21800        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21801          {"type":"text","text":"x","marks":[
21802            {"type":"backgroundColor","attrs":{"color":"#FF0000"}},
21803            {"type":"underline"}
21804          ]}
21805        ]}]}"##;
21806        assert_roundtrip_marks(adf_json, &["backgroundColor", "underline"]);
21807    }
21808
21809    #[test]
21810    fn issue_554_subsup_and_underline_preserved() {
21811        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21812          {"type":"text","text":"x","marks":[
21813            {"type":"subsup","attrs":{"type":"sub"}},
21814            {"type":"underline"}
21815          ]}
21816        ]}]}"#;
21817        assert_roundtrip_marks(adf_json, &["subsup", "underline"]);
21818    }
21819
21820    #[test]
21821    fn issue_554_exact_reproducer_full_match() {
21822        // The exact reproducer from issue #554. The byte-for-byte ADF JSON
21823        // must round-trip through `from-adf | to-adf` unchanged.
21824        let adf_json = r##"{
21825          "version": 1,
21826          "type": "doc",
21827          "content": [
21828            {
21829              "type": "paragraph",
21830              "content": [
21831                {"type":"text","text":"Status: ","marks":[{"type":"strong"}]},
21832                {"type":"text","text":"Approved","marks":[
21833                  {"type":"textColor","attrs":{"color":"#008000"}}
21834                ]},
21835                {"type":"text","text":" — ready to proceed"}
21836              ]
21837            }
21838          ]
21839        }"##;
21840        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21841        let md = adf_to_markdown(&doc).unwrap();
21842        assert!(
21843            md.contains(":span[Approved]{color=#008000}"),
21844            "JFM should contain green span: {md}"
21845        );
21846        let rt = markdown_to_adf(&md).unwrap();
21847        // Find the "Approved" text node and verify color is preserved.
21848        let approved = rt.content[0]
21849            .content
21850            .as_ref()
21851            .unwrap()
21852            .iter()
21853            .find(|n| n.text.as_deref() == Some("Approved"))
21854            .expect("Approved text node");
21855        let marks = approved.marks.as_ref().expect("should have marks");
21856        let color_mark = marks
21857            .iter()
21858            .find(|m| m.mark_type == "textColor")
21859            .expect("textColor mark must be preserved");
21860        assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#008000");
21861    }
21862
21863    #[test]
21864    fn issue_554_textcolor_with_code_renders_span_around_code() {
21865        // Verify the rendered JFM uses `:span[`text`]{color=...}` — the
21866        // syntax suggested in the issue.
21867        let doc = AdfDocument {
21868            version: 1,
21869            doc_type: "doc".to_string(),
21870            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
21871                "fn main",
21872                vec![
21873                    AdfMark::text_color("#008000"),
21874                    AdfMark {
21875                        mark_type: "code".to_string(),
21876                        attrs: None,
21877                    },
21878                ],
21879            )])],
21880        };
21881        let md = adf_to_markdown(&doc).unwrap();
21882        assert!(
21883            md.contains(":span[`fn main`]{color=#008000}"),
21884            "expected span-wrapped code, got: {md}"
21885        );
21886    }
21887
21888    #[test]
21889    fn issue_554_underline_with_code_renders_bracketed_around_code() {
21890        let doc = AdfDocument {
21891            version: 1,
21892            doc_type: "doc".to_string(),
21893            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
21894                "fn main",
21895                vec![
21896                    AdfMark::underline(),
21897                    AdfMark {
21898                        mark_type: "code".to_string(),
21899                        attrs: None,
21900                    },
21901                ],
21902            )])],
21903        };
21904        let md = adf_to_markdown(&doc).unwrap();
21905        assert!(
21906            md.contains("[`fn main`]{underline}"),
21907            "expected bracketed-span around code, got: {md}"
21908        );
21909    }
21910
21911    // ── Issue #554 (re-opened): boundary-underscore destroys span directives ──
21912
21913    #[test]
21914    fn issue_554_underscore_adjacent_to_textcolor_span_roundtrip() {
21915        // Reproducer from the re-opened issue: a `_ ` plain-text node followed
21916        // by a textColor span whose text starts with `_` produced JFM that the
21917        // parser saw as an italic delimiter pair, destroying the span and
21918        // losing the textColor mark entirely.
21919        let adf_json = r##"{
21920          "version": 1,
21921          "type": "doc",
21922          "content": [
21923            {
21924              "type": "paragraph",
21925              "content": [
21926                {"type":"text","text":"_ "},
21927                {"type":"text","text":"_Action:*","marks":[
21928                  {"type":"textColor","attrs":{"color":"#008000"}}
21929                ]},
21930                {"type":"text","text":" Complete the setup process."}
21931              ]
21932            }
21933          ]
21934        }"##;
21935        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21936        let md = adf_to_markdown(&doc).unwrap();
21937        // The leading `_` chars must be backslash-escaped so the parser
21938        // doesn't form a false italic pair across the span boundary.
21939        assert!(
21940            md.contains(r"\_ ") && md.contains(r":span[\_Action"),
21941            "underscores at node boundaries should be escaped: {md}"
21942        );
21943        let rt = markdown_to_adf(&md).unwrap();
21944        let para_content = rt.content[0].content.as_ref().unwrap();
21945        // Find the textColor-marked node.
21946        let colored = para_content
21947            .iter()
21948            .find(|n| {
21949                n.marks
21950                    .as_deref()
21951                    .is_some_and(|ms| ms.iter().any(|m| m.mark_type == "textColor"))
21952            })
21953            .expect("textColor node must be preserved");
21954        assert_eq!(colored.text.as_deref(), Some("_Action:*"));
21955        let color_mark = colored
21956            .marks
21957            .as_ref()
21958            .unwrap()
21959            .iter()
21960            .find(|m| m.mark_type == "textColor")
21961            .unwrap();
21962        assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#008000");
21963        // Verify no spurious em mark crept in.
21964        for n in para_content {
21965            if let Some(ms) = n.marks.as_deref() {
21966                assert!(
21967                    !ms.iter().any(|m| m.mark_type == "em"),
21968                    "no em mark should appear, got marks {:?}",
21969                    ms.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
21970                );
21971            }
21972        }
21973    }
21974
21975    #[test]
21976    fn issue_554_underscore_intraword_left_unescaped() {
21977        // Sanity check: ordinary intraword underscores like `do_something_useful`
21978        // should NOT be escaped — escaping would still round-trip correctly,
21979        // but produces noisy backslashes in the JFM output.
21980        let doc = AdfDocument {
21981            version: 1,
21982            doc_type: "doc".to_string(),
21983            content: vec![AdfNode::paragraph(vec![AdfNode::text(
21984                "call do_something_useful now",
21985            )])],
21986        };
21987        let md = adf_to_markdown(&doc).unwrap();
21988        assert!(
21989            md.contains("do_something_useful") && !md.contains(r"do\_something\_useful"),
21990            "intraword underscores should not be escaped: {md}"
21991        );
21992    }
21993
21994    #[test]
21995    fn issue_554_code_underline_then_textcolor_bracketed_outer() {
21996        // Mark order [underline, textColor, code] — bracketed-span outer,
21997        // span inner. Exercises wrap_with_attrs (true, true) !span_before.
21998        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21999          {"type":"text","text":"x","marks":[
22000            {"type":"underline"},
22001            {"type":"textColor","attrs":{"color":"#008000"}},
22002            {"type":"code"}
22003          ]}
22004        ]}]}"##;
22005        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22006        let md = adf_to_markdown(&doc).unwrap();
22007        // Bracketed-span should be the outermost wrapper.
22008        assert!(
22009            md.starts_with('[') && md.contains("underline}"),
22010            "bracketed-span should wrap the span, got: {md}"
22011        );
22012        let rt = markdown_to_adf(&md).unwrap();
22013        let node = &rt.content[0].content.as_ref().unwrap()[0];
22014        let mark_types: Vec<&str> = node
22015            .marks
22016            .as_ref()
22017            .unwrap()
22018            .iter()
22019            .map(|m| m.mark_type.as_str())
22020            .collect();
22021        assert_eq!(mark_types, vec!["underline", "textColor", "code"]);
22022    }
22023
22024    #[test]
22025    fn issue_554_textcolor_underline_link_all_preserved() {
22026        // Mark order [textColor, underline, link] — span outer, bracketed
22027        // wraps the link inside. Exercises the span-wraps-link-with-bracketed
22028        // branch.
22029        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22030          {"type":"text","text":"linked","marks":[
22031            {"type":"textColor","attrs":{"color":"#008000"}},
22032            {"type":"underline"},
22033            {"type":"link","attrs":{"href":"https://example.com"}}
22034          ]}
22035        ]}]}"##;
22036        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22037        let md = adf_to_markdown(&doc).unwrap();
22038        let rt = markdown_to_adf(&md).unwrap();
22039        let node = &rt.content[0].content.as_ref().unwrap()[0];
22040        let mark_types: Vec<&str> = node
22041            .marks
22042            .as_ref()
22043            .unwrap()
22044            .iter()
22045            .map(|m| m.mark_type.as_str())
22046            .collect();
22047        assert_eq!(mark_types, vec!["textColor", "underline", "link"]);
22048    }
22049
22050    #[test]
22051    fn issue_554_underline_textcolor_link_bracketed_outer_link_last() {
22052        // Mark order [underline, textColor, link] — bracketed-span outer of
22053        // both span and link. Exercises the bracketed-wraps-everything branch.
22054        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22055          {"type":"text","text":"linked","marks":[
22056            {"type":"underline"},
22057            {"type":"textColor","attrs":{"color":"#008000"}},
22058            {"type":"link","attrs":{"href":"https://example.com"}}
22059          ]}
22060        ]}]}"##;
22061        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22062        let md = adf_to_markdown(&doc).unwrap();
22063        let rt = markdown_to_adf(&md).unwrap();
22064        let node = &rt.content[0].content.as_ref().unwrap()[0];
22065        let mark_types: Vec<&str> = node
22066            .marks
22067            .as_ref()
22068            .unwrap()
22069            .iter()
22070            .map(|m| m.mark_type.as_str())
22071            .collect();
22072        assert_eq!(mark_types, vec!["underline", "textColor", "link"]);
22073    }
22074
22075    #[test]
22076    fn issue_554_link_underline_textcolor_link_outer() {
22077        // Mark order [link, underline, textColor] — link outermost, wraps a
22078        // bracketed-span that wraps the span. Exercises the link-wraps-
22079        // bracketed-wraps-span branch.
22080        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22081          {"type":"text","text":"linked","marks":[
22082            {"type":"link","attrs":{"href":"https://example.com"}},
22083            {"type":"underline"},
22084            {"type":"textColor","attrs":{"color":"#008000"}}
22085          ]}
22086        ]}]}"##;
22087        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22088        let md = adf_to_markdown(&doc).unwrap();
22089        assert!(
22090            md.starts_with('[') && md.contains("](https://example.com)"),
22091            "link should be outermost, got: {md}"
22092        );
22093        let rt = markdown_to_adf(&md).unwrap();
22094        let node = &rt.content[0].content.as_ref().unwrap()[0];
22095        let mark_types: Vec<&str> = node
22096            .marks
22097            .as_ref()
22098            .unwrap()
22099            .iter()
22100            .map(|m| m.mark_type.as_str())
22101            .collect();
22102        assert_eq!(mark_types, vec!["link", "underline", "textColor"]);
22103    }
22104
22105    #[test]
22106    fn issue_554_trailing_underscore_then_leading_underscore_round_trip() {
22107        // Two adjacent text nodes where the first ends with `_` and the
22108        // second starts with `_` — without escaping, the JFM parser sees
22109        // an `_..._` pair spanning the boundary.
22110        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22111          {"type":"text","text":"end_"},
22112          {"type":"text","text":"_start"}
22113        ]}]}"#;
22114        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22115        let md = adf_to_markdown(&doc).unwrap();
22116        let rt = markdown_to_adf(&md).unwrap();
22117        // Reassemble all text in the paragraph.
22118        let combined: String = rt.content[0]
22119            .content
22120            .as_ref()
22121            .unwrap()
22122            .iter()
22123            .filter_map(|n| n.text.as_deref())
22124            .collect();
22125        assert_eq!(combined, "end__start");
22126        // No node should have an em mark.
22127        for n in rt.content[0].content.as_ref().unwrap() {
22128            if let Some(ms) = n.marks.as_deref() {
22129                assert!(!ms.iter().any(|m| m.mark_type == "em"));
22130            }
22131        }
22132    }
22133
22134    // ── Nesting depth limit (issue #1130) ──────────────────────────────
22135    //
22136    // Without MAX_NESTING_DEPTH each of these inputs recurses one stack
22137    // frame per nesting level, overflowing the stack — an uncatchable
22138    // abort.  The block-path tests assert a clean error; the inline-path
22139    // tests assert graceful degradation (no abort, valid document).
22140
22141    #[test]
22142    fn blockquote_nesting_beyond_cap_errors() {
22143        let md = format!("{}x", "> ".repeat(200));
22144        let err = markdown_to_adf(&md).unwrap_err();
22145        assert!(err.to_string().contains("maximum depth"));
22146    }
22147
22148    #[test]
22149    fn list_nesting_beyond_cap_errors() {
22150        let mut md = String::new();
22151        for i in 0..200 {
22152            md.push_str(&"  ".repeat(i));
22153            md.push_str("- x\n");
22154        }
22155        let err = markdown_to_adf(&md).unwrap_err();
22156        assert!(err.to_string().contains("maximum depth"));
22157    }
22158
22159    #[test]
22160    fn container_directive_nesting_beyond_cap_errors() {
22161        let mut md = String::new();
22162        for _ in 0..200 {
22163            md.push_str(":::panel{type=info}\n");
22164        }
22165        md.push_str("body\n");
22166        for _ in 0..200 {
22167            md.push_str(":::\n");
22168        }
22169        let err = markdown_to_adf(&md).unwrap_err();
22170        assert!(err.to_string().contains("maximum depth"));
22171    }
22172
22173    #[test]
22174    fn directive_table_cell_nesting_beyond_cap_errors() {
22175        let mut md = String::new();
22176        for _ in 0..200 {
22177            md.push_str("::::table\n:::tr\n:::td\n");
22178        }
22179        md.push_str("cell\n");
22180        for _ in 0..200 {
22181            md.push_str(":::\n:::\n::::\n");
22182        }
22183        let err = markdown_to_adf(&md).unwrap_err();
22184        assert!(err.to_string().contains("maximum depth"));
22185    }
22186
22187    #[test]
22188    fn layout_column_nesting_beyond_cap_errors() {
22189        let mut md = String::new();
22190        for _ in 0..200 {
22191            md.push_str("::::layout\n:::column{width=100}\n");
22192        }
22193        md.push_str("x\n");
22194        for _ in 0..200 {
22195            md.push_str(":::\n::::\n");
22196        }
22197        let err = markdown_to_adf(&md).unwrap_err();
22198        assert!(err.to_string().contains("maximum depth"));
22199    }
22200
22201    #[test]
22202    fn blockquote_nesting_at_cap_boundary() {
22203        // A parser at depth d handles the (d+1)-th `>` level, so exactly
22204        // MAX_NESTING_DEPTH levels succeed and one more errors.
22205        let at_cap = format!("{}x", "> ".repeat(MAX_NESTING_DEPTH));
22206        assert!(markdown_to_adf(&at_cap).is_ok());
22207        let past_cap = format!("{}x", "> ".repeat(MAX_NESTING_DEPTH + 1));
22208        let err = markdown_to_adf(&past_cap).unwrap_err();
22209        assert!(err.to_string().contains("maximum depth"));
22210    }
22211
22212    #[test]
22213    fn list_nesting_at_cap_succeeds() {
22214        // The nested-list path has the largest debug-build stack frames, so
22215        // an at-cap success here proves the cap leaves stack headroom on a
22216        // default 2 MiB test thread (the canary for MAX_NESTING_DEPTH).
22217        let mut md = String::new();
22218        for i in 0..MAX_NESTING_DEPTH {
22219            md.push_str(&"  ".repeat(i));
22220            md.push_str("- x\n");
22221        }
22222        assert!(markdown_to_adf(&md).is_ok());
22223    }
22224
22225    #[test]
22226    fn deep_but_reasonable_nesting_succeeds() {
22227        let md = format!("{}deep", "> ".repeat(20));
22228        let doc = markdown_to_adf(&md).unwrap();
22229        // Walk down the 20 blockquote levels to the paragraph.
22230        let mut node = &doc.content[0];
22231        for _ in 0..20 {
22232            assert_eq!(node.node_type, "blockquote");
22233            node = &node.content.as_ref().unwrap()[0];
22234        }
22235        assert_eq!(node.node_type, "paragraph");
22236    }
22237
22238    #[test]
22239    fn deeply_nested_emphasis_degrades_without_overflow() {
22240        let md = format!("{}x{}", "*_".repeat(400), "_*".repeat(400));
22241        let doc = markdown_to_adf(&md).unwrap();
22242        assert_eq!(doc.content[0].node_type, "paragraph");
22243    }
22244
22245    #[test]
22246    fn deeply_nested_links_degrade_without_overflow() {
22247        let mut md = String::from("x");
22248        for _ in 0..400 {
22249            md = format!("[{md}](https://example.com)");
22250        }
22251        let doc = markdown_to_adf(&md).unwrap();
22252        assert_eq!(doc.content[0].node_type, "paragraph");
22253    }
22254
22255    #[test]
22256    fn deeply_nested_bracketed_spans_degrade_without_overflow() {
22257        let mut md = String::from("x");
22258        for _ in 0..400 {
22259            md = format!("[{md}]{{underline}}");
22260        }
22261        let doc = markdown_to_adf(&md).unwrap();
22262        assert_eq!(doc.content[0].node_type, "paragraph");
22263    }
22264}